@monkey-mini-app/ui 0.1.0

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 (149) hide show
  1. package/components.json +23 -0
  2. package/dist/globals.css +2 -0
  3. package/dist/index.js +138 -0
  4. package/dist/manifest.json +11 -0
  5. package/dist/src/blocks/activity-feed.tsx +9 -0
  6. package/dist/src/blocks/app-shell.tsx +37 -0
  7. package/dist/src/blocks/attachment-gallery.tsx +30 -0
  8. package/dist/src/blocks/bar-chart.tsx +33 -0
  9. package/dist/src/blocks/comment-thread.tsx +30 -0
  10. package/dist/src/blocks/description-list.tsx +16 -0
  11. package/dist/src/blocks/detail-panel.tsx +43 -0
  12. package/dist/src/blocks/donut-chart.tsx +38 -0
  13. package/dist/src/blocks/env-badge.tsx +23 -0
  14. package/dist/src/blocks/file-tree.tsx +5 -0
  15. package/dist/src/blocks/filter-bar.tsx +21 -0
  16. package/dist/src/blocks/gauge.tsx +13 -0
  17. package/dist/src/blocks/notification-center.tsx +22 -0
  18. package/dist/src/blocks/page-header.tsx +30 -0
  19. package/dist/src/blocks/progress-ring.tsx +25 -0
  20. package/dist/src/blocks/radar-chart.tsx +31 -0
  21. package/dist/src/blocks/request-inspector.tsx +34 -0
  22. package/dist/src/blocks/run-timeline.tsx +5 -0
  23. package/dist/src/blocks/scrollspy.tsx +42 -0
  24. package/dist/src/blocks/severity-chip.tsx +27 -0
  25. package/dist/src/blocks/sparkline.tsx +19 -0
  26. package/dist/src/blocks/stat-card.tsx +49 -0
  27. package/dist/src/blocks/status-badge.tsx +39 -0
  28. package/dist/src/blocks/terminal.tsx +10 -0
  29. package/dist/src/blocks/test-step-list.tsx +19 -0
  30. package/dist/src/blocks/trend-card.tsx +51 -0
  31. package/dist/src/components/accordion.tsx +72 -0
  32. package/dist/src/components/alert-dialog.tsx +185 -0
  33. package/dist/src/components/alert.tsx +76 -0
  34. package/dist/src/components/aspect-ratio.tsx +22 -0
  35. package/dist/src/components/attachment.tsx +207 -0
  36. package/dist/src/components/avatar.tsx +107 -0
  37. package/dist/src/components/badge.tsx +52 -0
  38. package/dist/src/components/breadcrumb.tsx +125 -0
  39. package/dist/src/components/bubble.tsx +128 -0
  40. package/dist/src/components/button-group.tsx +87 -0
  41. package/dist/src/components/button.tsx +58 -0
  42. package/dist/src/components/calendar.tsx +221 -0
  43. package/dist/src/components/card.tsx +103 -0
  44. package/dist/src/components/chart.tsx +371 -0
  45. package/dist/src/components/checkbox.tsx +29 -0
  46. package/dist/src/components/collapsible.tsx +19 -0
  47. package/dist/src/components/combobox.tsx +295 -0
  48. package/dist/src/components/command.tsx +196 -0
  49. package/dist/src/components/context-menu.tsx +271 -0
  50. package/dist/src/components/dialog.tsx +158 -0
  51. package/dist/src/components/drawer.tsx +226 -0
  52. package/dist/src/components/dropdown-menu.tsx +268 -0
  53. package/dist/src/components/empty.tsx +123 -0
  54. package/dist/src/components/field.tsx +236 -0
  55. package/dist/src/components/hover-card.tsx +49 -0
  56. package/dist/src/components/input-group.tsx +158 -0
  57. package/dist/src/components/input.tsx +20 -0
  58. package/dist/src/components/item.tsx +201 -0
  59. package/dist/src/components/kbd.tsx +26 -0
  60. package/dist/src/components/label.tsx +20 -0
  61. package/dist/src/components/marker.tsx +71 -0
  62. package/dist/src/components/menubar.tsx +280 -0
  63. package/dist/src/components/message-scroller.tsx +129 -0
  64. package/dist/src/components/message.tsx +92 -0
  65. package/dist/src/components/native-select.tsx +61 -0
  66. package/dist/src/components/navigation-menu.tsx +168 -0
  67. package/dist/src/components/pagination.tsx +135 -0
  68. package/dist/src/components/popover.tsx +88 -0
  69. package/dist/src/components/progress.tsx +83 -0
  70. package/dist/src/components/questionnaire.tsx +329 -0
  71. package/dist/src/components/radio-group.tsx +36 -0
  72. package/dist/src/components/resizable.tsx +50 -0
  73. package/dist/src/components/scroll-area.tsx +52 -0
  74. package/dist/src/components/select.tsx +201 -0
  75. package/dist/src/components/separator.tsx +23 -0
  76. package/dist/src/components/sheet.tsx +136 -0
  77. package/dist/src/components/sidebar.tsx +721 -0
  78. package/dist/src/components/skeleton.tsx +13 -0
  79. package/dist/src/components/slider.tsx +52 -0
  80. package/dist/src/components/spinner.tsx +10 -0
  81. package/dist/src/components/switch.tsx +32 -0
  82. package/dist/src/components/table.tsx +114 -0
  83. package/dist/src/components/tabs.tsx +82 -0
  84. package/dist/src/components/textarea.tsx +18 -0
  85. package/dist/src/components/toast.tsx +234 -0
  86. package/dist/src/components/toggle-group.tsx +89 -0
  87. package/dist/src/components/toggle.tsx +43 -0
  88. package/dist/src/components/tooltip.tsx +66 -0
  89. package/dist/src/composites/autocomplete.tsx +55 -0
  90. package/dist/src/composites/cascader.tsx +46 -0
  91. package/dist/src/composites/color-picker.tsx +19 -0
  92. package/dist/src/composites/confirm-dialog.tsx +56 -0
  93. package/dist/src/composites/copyable.tsx +37 -0
  94. package/dist/src/composites/currency-input.tsx +31 -0
  95. package/dist/src/composites/date-chrome.tsx +157 -0
  96. package/dist/src/composites/date-picker.tsx +91 -0
  97. package/dist/src/composites/date-range-picker.tsx +96 -0
  98. package/dist/src/composites/date-time-picker.tsx +115 -0
  99. package/dist/src/composites/date-time-range-picker.tsx +51 -0
  100. package/dist/src/composites/duration-input.tsx +122 -0
  101. package/dist/src/composites/mini-calendar.tsx +24 -0
  102. package/dist/src/composites/number-field.tsx +53 -0
  103. package/dist/src/composites/password-field.tsx +44 -0
  104. package/dist/src/composites/phone-input.tsx +22 -0
  105. package/dist/src/composites/rating.tsx +39 -0
  106. package/dist/src/composites/relative-date-picker.tsx +62 -0
  107. package/dist/src/composites/search-input.tsx +49 -0
  108. package/dist/src/composites/slider-range.tsx +30 -0
  109. package/dist/src/composites/tag-input.tsx +56 -0
  110. package/dist/src/composites/time-picker.tsx +199 -0
  111. package/dist/src/composites/time-range-picker.tsx +25 -0
  112. package/dist/src/composites/timezone-select.tsx +251 -0
  113. package/dist/src/composites/transfer.tsx +52 -0
  114. package/dist/src/composites/user-picker.tsx +40 -0
  115. package/dist/src/hooks/use-mobile.ts +19 -0
  116. package/dist/src/i18n/context.tsx +72 -0
  117. package/dist/src/i18n/en.ts +168 -0
  118. package/dist/src/i18n/zh.ts +160 -0
  119. package/dist/src/index.ts +134 -0
  120. package/dist/src/lib/icons.tsx +16 -0
  121. package/dist/src/lib/illustrations.tsx +573 -0
  122. package/dist/src/lib/utils.ts +6 -0
  123. package/dist/src/products/cells.tsx +18 -0
  124. package/dist/src/products/code-block.tsx +75 -0
  125. package/dist/src/products/code-editor.tsx +49 -0
  126. package/dist/src/products/column-meta.d.ts +9 -0
  127. package/dist/src/products/data-grid.tsx +689 -0
  128. package/dist/src/products/diff-viewer.tsx +129 -0
  129. package/dist/src/products/event-calendar.tsx +845 -0
  130. package/dist/src/products/export-csv.ts +25 -0
  131. package/dist/src/products/file-dropzone.tsx +41 -0
  132. package/dist/src/products/gantt.tsx +41 -0
  133. package/dist/src/products/jira-wiki-parse.ts +197 -0
  134. package/dist/src/products/jira-wiki.tsx +285 -0
  135. package/dist/src/products/jql-input.tsx +62 -0
  136. package/dist/src/products/jql-language.ts +70 -0
  137. package/dist/src/products/json-viewer.tsx +37 -0
  138. package/dist/src/products/kanban-issue-panel.tsx +98 -0
  139. package/dist/src/products/kanban.tsx +245 -0
  140. package/dist/src/products/log-viewer.tsx +33 -0
  141. package/dist/src/products/markdown-editor.tsx +63 -0
  142. package/dist/src/products/markdown.tsx +26 -0
  143. package/dist/src/products/rich-text-editor.tsx +84 -0
  144. package/dist/src/products/sortable-list.tsx +61 -0
  145. package/dist/src/products/stepper.tsx +89 -0
  146. package/dist/src/products/timeline.tsx +32 -0
  147. package/dist/src/products/tree-view.tsx +56 -0
  148. package/dist/src/styles/globals.css +352 -0
  149. package/package.json +100 -0
@@ -0,0 +1,689 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import {
5
+ flexRender,
6
+ getCoreRowModel,
7
+ getFilteredRowModel,
8
+ getPaginationRowModel,
9
+ getSortedRowModel,
10
+ useReactTable,
11
+ type Column,
12
+ type ColumnDef,
13
+ type ColumnFiltersState,
14
+ type RowSelectionState,
15
+ type SortingState,
16
+ type Table as TanstackTable,
17
+ type VisibilityState,
18
+ } from "@tanstack/react-table"
19
+ import {
20
+ ArrowDown,
21
+ ArrowUp,
22
+ ChevronLeft,
23
+ ChevronRight,
24
+ ChevronsUpDown,
25
+ Download,
26
+ ListFilter,
27
+ Search,
28
+ Settings2,
29
+ X,
30
+ } from "lucide-react"
31
+
32
+ import { Button } from "@monkey-mini-app/ui/components/button"
33
+ import { Checkbox } from "@monkey-mini-app/ui/components/checkbox"
34
+ import {
35
+ DropdownMenu,
36
+ DropdownMenuCheckboxItem,
37
+ DropdownMenuContent,
38
+ DropdownMenuGroup,
39
+ DropdownMenuLabel,
40
+ DropdownMenuTrigger,
41
+ } from "@monkey-mini-app/ui/components/dropdown-menu"
42
+ import {
43
+ Empty,
44
+ EmptyDescription,
45
+ EmptyHeader,
46
+ EmptyTitle,
47
+ } from "@monkey-mini-app/ui/components/empty"
48
+ import {
49
+ InputGroup,
50
+ InputGroupAddon,
51
+ InputGroupInput,
52
+ } from "@monkey-mini-app/ui/components/input-group"
53
+ import { NativeSelect, NativeSelectOption } from "@monkey-mini-app/ui/components/native-select"
54
+ import {
55
+ Popover,
56
+ PopoverContent,
57
+ PopoverTrigger,
58
+ } from "@monkey-mini-app/ui/components/popover"
59
+ import {
60
+ Table,
61
+ TableBody,
62
+ TableCell,
63
+ TableHead,
64
+ TableHeader,
65
+ TableRow,
66
+ } from "@monkey-mini-app/ui/components/table"
67
+ import { cn } from "@monkey-mini-app/ui/lib/utils"
68
+ import { useLabels } from "@monkey-mini-app/ui/i18n/context"
69
+ import { exportCsv } from "./export-csv"
70
+
71
+ export type { ColumnDef } from "@tanstack/react-table"
72
+
73
+ export type SearchVariant = "text" | "number" | "select" | "none"
74
+
75
+ export type DataGridColumnMeta = {
76
+ sort?: boolean
77
+ search?: SearchVariant
78
+ label?: string
79
+ }
80
+
81
+ export type DataGridFeatures = {
82
+ sorting?: boolean
83
+ columnSearch?: boolean
84
+ globalSearch?: boolean
85
+ pagination?: boolean
86
+ columnVisibility?: boolean
87
+ rowSelection?: boolean
88
+ columnResize?: boolean
89
+ csvExport?: boolean
90
+ /**
91
+ * TanStack `autoResetPageIndex` — reset to page 1 when the `data` array
92
+ * reference changes (default true). Turn off for inline edits / appends that
93
+ * must keep the current page; pair with a `key` remount for structural loads.
94
+ */
95
+ autoResetPageIndex?: boolean
96
+ }
97
+
98
+ export type DataGridProps<TData> = {
99
+ columns: ColumnDef<TData, unknown>[]
100
+ data: TData[]
101
+ features?: DataGridFeatures
102
+ pageSize?: number
103
+ /** Controlled current page (0-based). Omit for internal state. */
104
+ pageIndex?: number
105
+ /** Fired on every page change (also when the user flips pages). */
106
+ onPageIndexChange?: (pageIndex: number) => void
107
+ searchPlaceholder?: string
108
+ toolbar?: React.ReactNode
109
+ getRowId?: (row: TData) => string
110
+ renderExpanded?: (row: TData) => React.ReactNode
111
+ /** Fired when a row is clicked, with the row's data. */
112
+ onRowClick?: (row: TData) => void
113
+ onStateChange?: (state: {
114
+ sorting: SortingState
115
+ columnFilters: ColumnFiltersState
116
+ globalFilter: string
117
+ pagination: { pageIndex: number; pageSize: number }
118
+ }) => void
119
+ }
120
+
121
+ function columnLabel<TData>(column: Column<TData, unknown>) {
122
+ const meta = column.columnDef.meta as DataGridColumnMeta | undefined
123
+ if (meta?.label) return meta.label
124
+ if (typeof column.columnDef.header === "string") return column.columnDef.header
125
+ return column.id
126
+ }
127
+
128
+ function ColumnHeader<TData>({
129
+ column,
130
+ canSort,
131
+ canFilter,
132
+ }: {
133
+ column: Column<TData, unknown>
134
+ canSort: boolean
135
+ canFilter: boolean
136
+ }) {
137
+ const t = useLabels("dataGrid")
138
+ const sorted = column.getIsSorted()
139
+ const filterValue = (column.getFilterValue() as string) ?? ""
140
+ const filtered = filterValue.length > 0
141
+ const label = columnLabel(column)
142
+
143
+ return (
144
+ <div className="flex min-w-0 items-center gap-0.5">
145
+ {canSort ? (
146
+ <button
147
+ type="button"
148
+ data-testid={`sort-${column.id}`}
149
+ className="flex min-w-0 flex-1 items-center gap-1 rounded-md px-1 py-0.5 text-left text-xs font-medium tracking-wide text-muted-foreground uppercase hover:text-foreground"
150
+ onClick={() => {
151
+ if (sorted === "desc") column.clearSorting()
152
+ else column.toggleSorting(sorted === "asc")
153
+ }}
154
+ >
155
+ <span className="truncate">{label}</span>
156
+ {sorted === "asc" ? (
157
+ <ArrowUp className="size-3.5 shrink-0 text-foreground" />
158
+ ) : sorted === "desc" ? (
159
+ <ArrowDown className="size-3.5 shrink-0 text-foreground" />
160
+ ) : (
161
+ <ChevronsUpDown className="size-3.5 shrink-0 opacity-0 group-hover/th:opacity-40" />
162
+ )}
163
+ </button>
164
+ ) : (
165
+ <span className="truncate px-1 text-xs font-medium tracking-wide text-muted-foreground uppercase">
166
+ {label}
167
+ </span>
168
+ )}
169
+
170
+ {canFilter ? (
171
+ <Popover>
172
+ <PopoverTrigger
173
+ render={
174
+ <Button
175
+ variant="ghost"
176
+ size="icon-xs"
177
+ data-testid={`column-filter-${column.id}`}
178
+ aria-label={t.filterColumn(label)}
179
+ className={cn(
180
+ "shrink-0 text-muted-foreground",
181
+ filtered
182
+ ? "text-foreground"
183
+ : "opacity-0 group-hover/th:opacity-100 data-popup-open:opacity-100"
184
+ )}
185
+ />
186
+ }
187
+ >
188
+ <ListFilter className="size-3.5" />
189
+ </PopoverTrigger>
190
+ <PopoverContent align="start" className="w-56 p-2">
191
+ <InputGroup>
192
+ <InputGroupAddon>
193
+ <Search className="size-3.5" />
194
+ </InputGroupAddon>
195
+ <InputGroupInput
196
+ autoFocus
197
+ data-testid={`column-search-${column.id}`}
198
+ placeholder={t.filterColumn(label)}
199
+ value={filterValue}
200
+ onChange={(event) => column.setFilterValue(event.target.value)}
201
+ />
202
+ </InputGroup>
203
+ {filtered ? (
204
+ <Button
205
+ variant="ghost"
206
+ size="sm"
207
+ className="mt-1.5 w-full"
208
+ onClick={() => column.setFilterValue("")}
209
+ >
210
+ {t.clear}
211
+ </Button>
212
+ ) : null}
213
+ </PopoverContent>
214
+ </Popover>
215
+ ) : null}
216
+ </div>
217
+ )
218
+ }
219
+
220
+ function GridToolbar<TData>({
221
+ table,
222
+ enabled,
223
+ searchPlaceholder,
224
+ toolbar,
225
+ }: {
226
+ table: TanstackTable<TData>
227
+ enabled: Required<DataGridFeatures>
228
+ searchPlaceholder: string
229
+ toolbar?: React.ReactNode
230
+ }) {
231
+ const t = useLabels("dataGrid")
232
+ const globalFilter = (table.getState().globalFilter as string) ?? ""
233
+ const hideable = table
234
+ .getAllColumns()
235
+ .filter((column) => column.getCanHide() && column.accessorFn)
236
+
237
+ const chips = table.getState().columnFilters.filter(
238
+ (filter) => String(filter.value ?? "").length > 0
239
+ )
240
+
241
+ return (
242
+ <div className="flex flex-col gap-2">
243
+ <div className="flex flex-wrap items-center gap-2">
244
+ {enabled.globalSearch ? (
245
+ <InputGroup className="max-w-xs">
246
+ <InputGroupAddon>
247
+ <Search className="size-3.5" />
248
+ </InputGroupAddon>
249
+ <InputGroupInput
250
+ data-testid="data-grid-global-search"
251
+ placeholder={searchPlaceholder}
252
+ value={globalFilter}
253
+ onChange={(event) => table.setGlobalFilter(event.target.value)}
254
+ />
255
+ </InputGroup>
256
+ ) : null}
257
+
258
+ <div className="ml-auto flex items-center gap-2">
259
+ {enabled.csvExport ? (
260
+ <Button
261
+ variant="outline"
262
+ size="sm"
263
+ data-testid="data-grid-export"
264
+ onClick={() =>
265
+ exportCsv(
266
+ table.getFilteredRowModel().rows.map((row) => row.original as Record<string, unknown>)
267
+ )
268
+ }
269
+ >
270
+ <Download />
271
+ {t.export}
272
+ </Button>
273
+ ) : null}
274
+ {toolbar}
275
+ {enabled.columnVisibility && hideable.length > 0 ? (
276
+ <DropdownMenu>
277
+ <DropdownMenuTrigger
278
+ render={<Button variant="outline" size="sm" />}
279
+ >
280
+ <Settings2 />
281
+ {t.view}
282
+ </DropdownMenuTrigger>
283
+ <DropdownMenuContent align="end" className="w-44">
284
+ <DropdownMenuGroup>
285
+ <DropdownMenuLabel>{t.columns}</DropdownMenuLabel>
286
+ {hideable.map((column) => (
287
+ <DropdownMenuCheckboxItem
288
+ key={column.id}
289
+ checked={column.getIsVisible()}
290
+ onCheckedChange={(value) =>
291
+ column.toggleVisibility(!!value)
292
+ }
293
+ className="capitalize"
294
+ >
295
+ {columnLabel(column)}
296
+ </DropdownMenuCheckboxItem>
297
+ ))}
298
+ </DropdownMenuGroup>
299
+ </DropdownMenuContent>
300
+ </DropdownMenu>
301
+ ) : null}
302
+ </div>
303
+ </div>
304
+
305
+ {chips.length > 0 ? (
306
+ <div className="flex flex-wrap items-center gap-1.5">
307
+ {chips.map((filter) => {
308
+ const column = table.getColumn(filter.id)
309
+ if (!column) return null
310
+ return (
311
+ <button
312
+ key={filter.id}
313
+ type="button"
314
+ className="inline-flex h-6 items-center gap-1 rounded-md border bg-muted/60 px-1.5 text-xs text-muted-foreground hover:text-foreground"
315
+ onClick={() => column.setFilterValue("")}
316
+ >
317
+ <span className="font-medium text-foreground">
318
+ {columnLabel(column)}
319
+ </span>
320
+ <span className="max-w-28 truncate">{String(filter.value)}</span>
321
+ <X className="size-3" />
322
+ </button>
323
+ )
324
+ })}
325
+ </div>
326
+ ) : null}
327
+ </div>
328
+ )
329
+ }
330
+
331
+ /**
332
+ * Product table: sort, column filter popover, global search, pagination.
333
+ * @when Any list/table of rows — prefer over raw Table*
334
+ * @example
335
+ * const columns: ColumnDef<Row>[] = [
336
+ * { accessorKey: "name", header: "Name", meta: { sort: true, search: "text" } },
337
+ * ]
338
+ * <DataGrid columns={columns} data={rows} pageSize={20} />
339
+ *
340
+ * Pagination resets to page 1 whenever the `data` reference changes
341
+ * (TanStack `autoResetPageIndex`, default true) — right for filter/refresh
342
+ * flows. For inline edits / streaming appends that must keep the current page,
343
+ * set `features={{ autoResetPageIndex: false }}` and drive page resets with
344
+ * the controlled `pageIndex` prop:
345
+ *
346
+ * @example
347
+ * const [page, setPage] = useState(0)
348
+ * <DataGrid
349
+ * columns={columns}
350
+ * data={rows}
351
+ * features={{ autoResetPageIndex: false }}
352
+ * pageIndex={page}
353
+ * onPageIndexChange={setPage}
354
+ * />
355
+ * // “回到第 1 页”按钮:setPage(0);结构性重载也可用 key 重挂
356
+ */
357
+ export function DataGrid<TData>({
358
+ columns,
359
+ data,
360
+ features,
361
+ pageSize = 10,
362
+ pageIndex,
363
+ onPageIndexChange,
364
+ searchPlaceholder,
365
+ toolbar,
366
+ getRowId,
367
+ renderExpanded,
368
+ onRowClick,
369
+ onStateChange,
370
+ }: DataGridProps<TData>) {
371
+ const t = useLabels("dataGrid")
372
+ const enabled: Required<DataGridFeatures> = {
373
+ sorting: features?.sorting ?? true,
374
+ columnSearch: features?.columnSearch ?? true,
375
+ globalSearch: features?.globalSearch ?? true,
376
+ pagination: features?.pagination ?? true,
377
+ columnVisibility: features?.columnVisibility ?? true,
378
+ rowSelection: features?.rowSelection ?? false,
379
+ columnResize: features?.columnResize ?? false,
380
+ csvExport: features?.csvExport ?? false,
381
+ autoResetPageIndex: features?.autoResetPageIndex ?? true,
382
+ }
383
+
384
+ const [sorting, setSorting] = React.useState<SortingState>([])
385
+ const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
386
+ []
387
+ )
388
+ const [globalFilter, setGlobalFilter] = React.useState("")
389
+ const [columnVisibility, setColumnVisibility] =
390
+ React.useState<VisibilityState>({})
391
+ const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
392
+ const [expandedId, setExpandedId] = React.useState<string | null>(null)
393
+
394
+ const selectionColumn = React.useMemo<ColumnDef<TData, unknown>[]>(() => {
395
+ if (!enabled.rowSelection) return []
396
+ return [
397
+ {
398
+ id: "_select",
399
+ header: ({ table }) => (
400
+ <Checkbox
401
+ checked={table.getIsAllPageRowsSelected()}
402
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
403
+ aria-label={t.selectAll}
404
+ />
405
+ ),
406
+ cell: ({ row }) => (
407
+ <Checkbox
408
+ checked={row.getIsSelected()}
409
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
410
+ aria-label={t.selectRow}
411
+ />
412
+ ),
413
+ enableSorting: false,
414
+ enableHiding: false,
415
+ },
416
+ ]
417
+ }, [enabled.rowSelection, t.selectAll, t.selectRow])
418
+
419
+ const [internalPagination, setInternalPagination] = React.useState({
420
+ pageIndex: 0,
421
+ pageSize,
422
+ })
423
+ const pagination =
424
+ pageIndex != null
425
+ ? { ...internalPagination, pageIndex }
426
+ : internalPagination
427
+
428
+ const table = useReactTable({
429
+ data,
430
+ columns: [...selectionColumn, ...columns],
431
+ state: {
432
+ sorting: enabled.sorting ? sorting : [],
433
+ columnFilters: enabled.columnSearch ? columnFilters : [],
434
+ globalFilter: enabled.globalSearch ? globalFilter : "",
435
+ columnVisibility,
436
+ rowSelection,
437
+ pagination,
438
+ },
439
+ onPaginationChange: (updater) => {
440
+ const next =
441
+ typeof updater === "function"
442
+ ? updater(pagination)
443
+ : updater
444
+ setInternalPagination(next)
445
+ onPageIndexChange?.(next.pageIndex)
446
+ },
447
+ onSortingChange: setSorting,
448
+ onColumnFiltersChange: setColumnFilters,
449
+ onGlobalFilterChange: setGlobalFilter,
450
+ onColumnVisibilityChange: setColumnVisibility,
451
+ onRowSelectionChange: setRowSelection,
452
+ getCoreRowModel: getCoreRowModel(),
453
+ getSortedRowModel: getSortedRowModel(),
454
+ getFilteredRowModel: getFilteredRowModel(),
455
+ getPaginationRowModel: getPaginationRowModel(),
456
+ enableSorting: enabled.sorting,
457
+ enableFilters: enabled.columnSearch,
458
+ enableGlobalFilter: enabled.globalSearch,
459
+ enableRowSelection: enabled.rowSelection,
460
+ enableColumnResizing: enabled.columnResize,
461
+ columnResizeMode: "onChange",
462
+ getRowId,
463
+ autoResetPageIndex: enabled.autoResetPageIndex,
464
+ })
465
+
466
+ React.useEffect(() => {
467
+ onStateChange?.({
468
+ sorting,
469
+ columnFilters,
470
+ globalFilter,
471
+ pagination: table.getState().pagination,
472
+ })
473
+ }, [sorting, columnFilters, globalFilter, table.getState().pagination.pageIndex, table.getState().pagination.pageSize])
474
+
475
+ const rows = table.getRowModel().rows
476
+ const filtered = table.getFilteredRowModel().rows.length
477
+ const pageCount = table.getPageCount() || 1
478
+ const currentPageIndex = table.getState().pagination.pageIndex
479
+ const selectedCount = table.getFilteredSelectedRowModel().rows.length
480
+
481
+ return (
482
+ <div
483
+ className="overflow-hidden rounded-xl border bg-card"
484
+ data-testid="data-grid"
485
+ >
486
+ <div className="border-b px-3 py-2">
487
+ <GridToolbar
488
+ table={table}
489
+ enabled={enabled}
490
+ searchPlaceholder={searchPlaceholder ?? t.search}
491
+ toolbar={toolbar}
492
+ />
493
+ </div>
494
+
495
+ <div className="overflow-x-auto">
496
+ <Table>
497
+ <TableHeader className="sticky top-0 z-10 bg-muted/40">
498
+ {table.getHeaderGroups().map((headerGroup) => (
499
+ <TableRow key={headerGroup.id} className="hover:bg-transparent">
500
+ {headerGroup.headers.map((header) => {
501
+ const meta = header.column.columnDef.meta as
502
+ | DataGridColumnMeta
503
+ | undefined
504
+ const canSort =
505
+ enabled.sorting &&
506
+ header.column.getCanSort() &&
507
+ meta?.sort !== false
508
+ const search = meta?.search ?? "text"
509
+ const canFilter =
510
+ enabled.columnSearch &&
511
+ search !== "none" &&
512
+ header.column.getCanFilter()
513
+
514
+ return (
515
+ <TableHead
516
+ key={header.id}
517
+ className="group/th relative h-9 px-3"
518
+ style={{ width: header.getSize() }}
519
+ >
520
+ {header.isPlaceholder ? null : typeof header.column
521
+ .columnDef.header === "function" &&
522
+ !canSort &&
523
+ !canFilter ? (
524
+ flexRender(
525
+ header.column.columnDef.header,
526
+ header.getContext()
527
+ )
528
+ ) : (
529
+ <ColumnHeader
530
+ column={header.column}
531
+ canSort={canSort}
532
+ canFilter={canFilter}
533
+ />
534
+ )}
535
+ {enabled.columnResize && header.column.getCanResize() ? (
536
+ <div
537
+ onMouseDown={header.getResizeHandler()}
538
+ className="absolute top-0 right-0 h-full w-1 cursor-col-resize"
539
+ />
540
+ ) : null}
541
+ </TableHead>
542
+ )
543
+ })}
544
+ </TableRow>
545
+ ))}
546
+ </TableHeader>
547
+ <TableBody>
548
+ {rows.length ? (
549
+ rows.map((row) => (
550
+ <React.Fragment key={row.id}>
551
+ <TableRow
552
+ data-testid="data-grid-row"
553
+ className="h-10"
554
+ data-state={row.getIsSelected() && "selected"}
555
+ onClick={() => {
556
+ onRowClick?.(row.original)
557
+ if (renderExpanded) setExpandedId((id) => (id === row.id ? null : row.id))
558
+ }}
559
+ >
560
+ {row.getVisibleCells().map((cell) => (
561
+ <TableCell key={cell.id} className="px-3 py-0">
562
+ {flexRender(
563
+ cell.column.columnDef.cell,
564
+ cell.getContext()
565
+ )}
566
+ </TableCell>
567
+ ))}
568
+ </TableRow>
569
+ {renderExpanded && expandedId === row.id ? (
570
+ <TableRow className="hover:bg-transparent">
571
+ <TableCell colSpan={row.getVisibleCells().length} className="bg-muted/30 px-3 py-3">
572
+ {renderExpanded(row.original)}
573
+ </TableCell>
574
+ </TableRow>
575
+ ) : null}
576
+ </React.Fragment>
577
+ ))
578
+ ) : (
579
+ <TableRow className="hover:bg-transparent">
580
+ <TableCell
581
+ colSpan={columns.length}
582
+ className="h-auto p-0"
583
+ data-testid="data-grid-empty"
584
+ >
585
+ <Empty className="border-0 py-14">
586
+ <EmptyHeader>
587
+ <EmptyTitle>{t.noResults}</EmptyTitle>
588
+ <EmptyDescription>
589
+ {t.noResultsHint}
590
+ </EmptyDescription>
591
+ </EmptyHeader>
592
+ {filtered === 0 && data.length > 0 ? (
593
+ <Button
594
+ variant="outline"
595
+ size="sm"
596
+ onClick={() => {
597
+ table.resetColumnFilters()
598
+ table.setGlobalFilter("")
599
+ }}
600
+ >
601
+ {t.clearFilters}
602
+ </Button>
603
+ ) : null}
604
+ </Empty>
605
+ </TableCell>
606
+ </TableRow>
607
+ )}
608
+ </TableBody>
609
+ </Table>
610
+ </div>
611
+
612
+ {enabled.pagination || selectedCount > 0 ? (
613
+ <div className="flex h-11 flex-wrap items-center justify-between gap-3 border-t px-3 text-xs text-muted-foreground">
614
+ <div className="flex items-center gap-2.5">
615
+ {enabled.pagination ? (
616
+ <span className="tabular-nums">
617
+ <span className="font-medium text-foreground">{filtered}</span>{" "}
618
+ {filtered === 1 ? t.row : t.rows}
619
+ </span>
620
+ ) : null}
621
+ {selectedCount > 0 ? (
622
+ <span
623
+ className="inline-flex items-center gap-1.5 rounded-md border bg-muted/50 py-0.5 pr-0.5 pl-2 text-foreground"
624
+ data-testid="data-grid-action-bar"
625
+ >
626
+ <span className="tabular-nums font-medium">{t.selected(selectedCount)}</span>
627
+ <Button
628
+ size="sm"
629
+ variant="ghost"
630
+ className="text-muted-foreground hover:text-foreground h-6 gap-1 px-1.5 text-xs"
631
+ aria-label={t.clear}
632
+ onClick={() => table.resetRowSelection()}
633
+ >
634
+ <X className="size-3" />
635
+ {t.clear}
636
+ </Button>
637
+ </span>
638
+ ) : null}
639
+ </div>
640
+ {enabled.pagination ? (
641
+ <div className="flex items-center gap-3">
642
+ <label className="flex items-center gap-2">
643
+ <span className="hidden sm:inline">{t.rowsLabel}</span>
644
+ <NativeSelect
645
+ size="sm"
646
+ value={String(table.getState().pagination.pageSize)}
647
+ onChange={(event) =>
648
+ table.setPageSize(Number(event.target.value))
649
+ }
650
+ >
651
+ {[10, 20, 30, 50].map((size) => (
652
+ <NativeSelectOption key={size} value={String(size)}>
653
+ {size}
654
+ </NativeSelectOption>
655
+ ))}
656
+ </NativeSelect>
657
+ </label>
658
+ <span data-testid="data-grid-page">
659
+ {currentPageIndex + 1} / {pageCount}
660
+ </span>
661
+ <div className="flex items-center gap-1">
662
+ <Button
663
+ variant="outline"
664
+ size="icon-xs"
665
+ data-testid="data-grid-prev"
666
+ aria-label={t.previousPage}
667
+ onClick={() => table.previousPage()}
668
+ disabled={!table.getCanPreviousPage()}
669
+ >
670
+ <ChevronLeft className="size-3.5" />
671
+ </Button>
672
+ <Button
673
+ variant="outline"
674
+ size="icon-xs"
675
+ data-testid="data-grid-next"
676
+ aria-label={t.nextPage}
677
+ onClick={() => table.nextPage()}
678
+ disabled={!table.getCanNextPage()}
679
+ >
680
+ <ChevronRight className="size-3.5" />
681
+ </Button>
682
+ </div>
683
+ </div>
684
+ ) : null}
685
+ </div>
686
+ ) : null}
687
+ </div>
688
+ )
689
+ }