@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.
- package/dist/{build-MFWRPYKW.js → build-X5NVCQKT.js} +4 -4
- package/dist/{build-MFWRPYKW.js.map → build-X5NVCQKT.js.map} +1 -1
- package/dist/{chunk-IBGOCWJB.js → chunk-2ICNHUG6.js} +186 -82
- package/dist/chunk-2ICNHUG6.js.map +1 -0
- package/dist/{chunk-5ZOP4SQR.js → chunk-2N72FKLB.js} +12 -7
- package/dist/chunk-2N72FKLB.js.map +1 -0
- package/dist/{chunk-PXRX5PEC.js → chunk-PDOBHMUQ.js} +3 -3
- package/dist/chunk-PDOBHMUQ.js.map +1 -0
- package/dist/{chunk-A2UUGASH.js → chunk-U736PVHY.js} +2 -2
- package/dist/chunk-U736PVHY.js.map +1 -0
- package/dist/{chunk-QEWCYW3I.js → chunk-WAMZWZJB.js} +13 -3
- package/dist/chunk-WAMZWZJB.js.map +1 -0
- package/dist/chunk-WRSX2MDK.js +7866 -0
- package/dist/chunk-WRSX2MDK.js.map +1 -0
- package/dist/{chunk-OB6UBJXF.js → chunk-XPQSTZUU.js} +125 -35
- package/dist/chunk-XPQSTZUU.js.map +1 -0
- package/dist/cli.js +3 -3
- package/dist/components/index.d.ts +1831 -99
- package/dist/components/index.js +4 -4
- package/dist/{dev-DCTOHQ6W.js → dev-4XEDTELV.js} +4 -4
- package/dist/{dev-DCTOHQ6W.js.map → dev-4XEDTELV.js.map} +1 -1
- package/dist/doc/index.js +2 -2
- package/dist/doc/index.js.map +1 -1
- package/dist/markdown/index.js +1 -1
- package/dist/markdown/index.js.map +1 -1
- package/dist/table/index.js +5 -5
- package/dist/table/index.js.map +1 -1
- package/dist/ui/index.d.ts +4 -1
- package/dist/ui/index.js +21 -111
- package/dist/ui/index.js.map +1 -1
- package/dist/{upgrade-DBBE6GIR.js → upgrade-JHXZBGQS.js} +3 -3
- package/dist/{upgrade-DBBE6GIR.js.map → upgrade-JHXZBGQS.js.map} +1 -1
- package/dist/vite/index.js +2 -2
- package/docs/en/components.md +29 -15
- package/docs/en/parts/data-table.md +22 -0
- package/docs/en/parts/delta.md +50 -0
- package/docs/en/parts/format.md +43 -0
- package/docs/en/parts/kpi-card.md +57 -0
- package/docs/en/parts/metric-value.md +3 -2
- package/docs/en/parts/sparkline.md +1 -1
- package/docs/en/parts/trend-indicator.md +17 -1
- package/docs/en/ui-and-theming.md +1 -1
- package/docs/index.json +92 -32
- package/docs/ja/changelog.md +5 -0
- package/docs/ja/components.md +27 -15
- package/docs/ja/parts/data-table.md +22 -0
- package/docs/ja/parts/delta.md +50 -0
- package/docs/ja/parts/format.md +44 -0
- package/docs/ja/parts/kpi-card.md +55 -0
- package/docs/ja/parts/metric-value.md +3 -2
- package/docs/ja/parts/sparkline.md +1 -1
- package/docs/ja/parts/trend-indicator.md +17 -1
- package/docs/ja/ui-and-theming.md +2 -2
- package/package.json +2 -1
- package/registry/ui/accordion.tsx +4 -2
- package/registry/ui/alert-dialog.tsx +3 -3
- package/registry/ui/alert.tsx +3 -1
- package/registry/ui/app-shell.tsx +7 -1
- package/registry/ui/area-chart.tsx +2781 -157
- package/registry/ui/avatar.tsx +1 -1
- package/registry/ui/badge.tsx +3 -1
- package/registry/ui/bar-chart.tsx +2780 -157
- package/registry/ui/bar-list.tsx +3471 -93
- package/registry/ui/breadcrumb.tsx +3 -1
- package/registry/ui/button.tsx +3 -1
- package/registry/ui/calendar.tsx +3 -1
- package/registry/ui/card.tsx +4 -2
- package/registry/ui/category-bar.tsx +990 -16
- package/registry/ui/chart-frame.tsx +1358 -0
- package/registry/ui/checkbox.tsx +3 -1
- package/registry/ui/collapsible.tsx +3 -1
- package/registry/ui/combobox.tsx +2 -2
- package/registry/ui/command.tsx +3 -1
- package/registry/ui/dashboard-card.tsx +69 -26
- package/registry/ui/data-table.tsx +678 -56
- package/registry/ui/date-range-picker.tsx +245 -64
- package/registry/ui/delta.tsx +188 -0
- package/registry/ui/dialog.tsx +5 -3
- package/registry/ui/donut-chart.tsx +2857 -173
- package/registry/ui/drawer.tsx +1 -1
- package/registry/ui/dropdown-menu.tsx +5 -3
- package/registry/ui/echart.tsx +448 -44
- package/registry/ui/field.tsx +1 -1
- package/registry/ui/filter-bar.tsx +33 -6
- package/registry/ui/funnel-steps.tsx +3316 -49
- package/registry/ui/heatmap.tsx +1752 -0
- package/registry/ui/hover-card.tsx +2 -2
- package/registry/ui/input-group.tsx +3 -1
- package/registry/ui/input.tsx +3 -1
- package/registry/ui/kbd.tsx +3 -1
- package/registry/ui/kpi-card.tsx +677 -0
- package/registry/ui/label.tsx +3 -1
- package/registry/ui/legend.tsx +3500 -0
- package/registry/ui/line-chart.tsx +2781 -151
- package/registry/ui/metric-value.tsx +9 -1
- package/registry/ui/multi-select.tsx +11 -5
- package/registry/ui/page-shell.tsx +104 -49
- package/registry/ui/pagination.tsx +3 -1
- package/registry/ui/placeholder.tsx +8 -2
- package/registry/ui/popover.tsx +4 -2
- package/registry/ui/progress-circle.tsx +607 -12
- package/registry/ui/progress.tsx +3 -1
- package/registry/ui/radio-group.tsx +1 -1
- package/registry/ui/sample-data.tsx +86 -0
- package/registry/ui/scatter-chart.tsx +4150 -0
- package/registry/ui/scroll-area.tsx +3 -2
- package/registry/ui/searchable-select.tsx +8 -3
- package/registry/ui/section-header.tsx +4 -2
- package/registry/ui/segmented-control.tsx +7 -2
- package/registry/ui/select.tsx +4 -2
- package/registry/ui/separator.tsx +3 -1
- package/registry/ui/sheet.tsx +4 -2
- package/registry/ui/sidebar.tsx +4 -2
- package/registry/ui/skeleton.tsx +4 -2
- package/registry/ui/slider.tsx +3 -1
- package/registry/ui/sparkline.tsx +84 -9
- package/registry/ui/spinner.tsx +3 -1
- package/registry/ui/states.tsx +34 -5
- package/registry/ui/status-badge.tsx +81 -39
- package/registry/ui/switch.tsx +3 -1
- package/registry/ui/table.tsx +4 -2
- package/registry/ui/tabs.tsx +5 -3
- package/registry/ui/textarea.tsx +3 -1
- package/registry/ui/toast.tsx +2 -2
- package/registry/ui/toggle-group.tsx +3 -1
- package/registry/ui/toggle.tsx +3 -1
- package/registry/ui/tooltip.tsx +4 -2
- package/registry/ui/tracker.tsx +624 -17
- package/registry/ui/trend-indicator.tsx +13 -1
- package/registry/ui/updated-at.tsx +122 -0
- package/registry/ui/waterfall-chart.tsx +4186 -0
- package/templates/AGENTS.md +13 -1
- package/theme.css +237 -136
- package/dist/chunk-5ZOP4SQR.js.map +0 -1
- package/dist/chunk-A2UUGASH.js.map +0 -1
- package/dist/chunk-AG6RZ2RR.js +0 -3208
- package/dist/chunk-AG6RZ2RR.js.map +0 -1
- package/dist/chunk-IBGOCWJB.js.map +0 -1
- package/dist/chunk-OB6UBJXF.js.map +0 -1
- package/dist/chunk-PXRX5PEC.js.map +0 -1
- 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/
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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, ...
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
:
|
|
887
|
+
: sorted === "desc"
|
|
428
888
|
? "descending"
|
|
429
889
|
: "none"
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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 =
|
|
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
|
-
{
|
|
511
|
-
<SelectItem key={
|
|
512
|
-
{
|
|
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
|