@object-ui/components 3.3.0 → 3.3.1

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 (321) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +21 -1
  3. package/dist/index.css +6339 -2
  4. package/dist/index.js +17600 -17481
  5. package/dist/index.umd.cjs +36 -36
  6. package/dist/packages/components/src/custom/empty.d.ts +12 -1
  7. package/dist/packages/components/src/renderers/action/action-bar.d.ts +12 -1
  8. package/dist/packages/components/src/ui/chart.d.ts +10 -29
  9. package/package.json +65 -44
  10. package/.turbo/turbo-build.log +0 -84
  11. package/README_SHADCN_SYNC.md +0 -281
  12. package/TESTING.md +0 -335
  13. package/docs/FilterBuilder.md +0 -268
  14. package/metadata/Chart.component.yml +0 -30
  15. package/metadata/FilterBuilder.component.yml +0 -39
  16. package/metadata/GridLayout.component.yml +0 -27
  17. package/metadata/Menu.component.yml +0 -31
  18. package/metadata/ObjectForm.component.yml +0 -34
  19. package/metadata/ObjectGrid.component.yml +0 -72
  20. package/metadata/Page.component.yml +0 -24
  21. package/postcss.config.js +0 -14
  22. package/shadcn-components.json +0 -440
  23. package/src/SchemaRenderer.tsx +0 -28
  24. package/src/__tests__/PageRendererRegions.test.tsx +0 -668
  25. package/src/__tests__/README.md +0 -124
  26. package/src/__tests__/__snapshots__/snapshot-critical.test.tsx.snap +0 -811
  27. package/src/__tests__/__snapshots__/snapshot.test.tsx.snap +0 -327
  28. package/src/__tests__/accessibility.test.tsx +0 -137
  29. package/src/__tests__/action-bar.test.tsx +0 -206
  30. package/src/__tests__/api-consistency.test.tsx +0 -596
  31. package/src/__tests__/basic-renderers.test.tsx +0 -255
  32. package/src/__tests__/color-contrast.test.tsx +0 -212
  33. package/src/__tests__/complex-disclosure-renderers.test.tsx +0 -302
  34. package/src/__tests__/compliance.test.tsx +0 -72
  35. package/src/__tests__/config-field-renderer.test.tsx +0 -307
  36. package/src/__tests__/config-panel-renderer.test.tsx +0 -580
  37. package/src/__tests__/config-primitives.test.tsx +0 -106
  38. package/src/__tests__/edge-cases.test.tsx +0 -285
  39. package/src/__tests__/feedback-overlay-renderers.test.tsx +0 -349
  40. package/src/__tests__/filter-builder.test.tsx +0 -409
  41. package/src/__tests__/form-renderers.test.tsx +0 -364
  42. package/src/__tests__/layout-data-renderers.test.tsx +0 -340
  43. package/src/__tests__/mobile-accessibility.test.tsx +0 -120
  44. package/src/__tests__/navigation-overlay.test.tsx +0 -370
  45. package/src/__tests__/snapshot-critical.test.tsx +0 -317
  46. package/src/__tests__/snapshot.test.tsx +0 -205
  47. package/src/__tests__/test-utils.tsx +0 -190
  48. package/src/__tests__/use-config-draft.test.tsx +0 -295
  49. package/src/__tests__/view-compliance.test.tsx +0 -153
  50. package/src/__tests__/wcag-audit.test.tsx +0 -493
  51. package/src/custom/action-param-dialog.tsx +0 -264
  52. package/src/custom/button-group.tsx +0 -91
  53. package/src/custom/combobox.tsx +0 -104
  54. package/src/custom/config-field-renderer.tsx +0 -276
  55. package/src/custom/config-panel-renderer.tsx +0 -306
  56. package/src/custom/config-row.tsx +0 -50
  57. package/src/custom/date-picker.tsx +0 -61
  58. package/src/custom/empty.tsx +0 -112
  59. package/src/custom/field.tsx +0 -81
  60. package/src/custom/filter-builder.tsx +0 -418
  61. package/src/custom/index.ts +0 -21
  62. package/src/custom/input-group.tsx +0 -53
  63. package/src/custom/item.tsx +0 -201
  64. package/src/custom/kbd.tsx +0 -36
  65. package/src/custom/mobile-dialog-content.tsx +0 -67
  66. package/src/custom/native-select.tsx +0 -33
  67. package/src/custom/navigation-overlay.tsx +0 -334
  68. package/src/custom/section-header.tsx +0 -68
  69. package/src/custom/sort-builder.tsx +0 -129
  70. package/src/custom/spinner.tsx +0 -26
  71. package/src/custom/view-skeleton.tsx +0 -243
  72. package/src/custom/view-states.tsx +0 -153
  73. package/src/debug/DebugPanel.tsx +0 -313
  74. package/src/debug/__tests__/DebugPanel.test.tsx +0 -134
  75. package/src/debug/index.ts +0 -10
  76. package/src/hooks/use-config-draft.ts +0 -127
  77. package/src/hooks/use-mobile.tsx +0 -27
  78. package/src/index.css +0 -245
  79. package/src/index.ts +0 -47
  80. package/src/lib/use-sync-external-store-shim.ts +0 -10
  81. package/src/lib/use-sync-external-store-with-selector-shim.ts +0 -90
  82. package/src/lib/utils.tsx +0 -35
  83. package/src/new-components.test.ts +0 -73
  84. package/src/renderers/action/action-bar.tsx +0 -221
  85. package/src/renderers/action/action-button.tsx +0 -158
  86. package/src/renderers/action/action-group.tsx +0 -270
  87. package/src/renderers/action/action-icon.tsx +0 -150
  88. package/src/renderers/action/action-menu.tsx +0 -203
  89. package/src/renderers/action/index.ts +0 -19
  90. package/src/renderers/action/resolve-icon.ts +0 -35
  91. package/src/renderers/basic/button-group.tsx +0 -79
  92. package/src/renderers/basic/div.tsx +0 -60
  93. package/src/renderers/basic/html.tsx +0 -43
  94. package/src/renderers/basic/icon.tsx +0 -89
  95. package/src/renderers/basic/image.tsx +0 -49
  96. package/src/renderers/basic/index.ts +0 -18
  97. package/src/renderers/basic/navigation-menu.tsx +0 -81
  98. package/src/renderers/basic/pagination.tsx +0 -109
  99. package/src/renderers/basic/separator.tsx +0 -57
  100. package/src/renderers/basic/span.tsx +0 -63
  101. package/src/renderers/basic/text.tsx +0 -52
  102. package/src/renderers/complex/README-KANBAN.md +0 -208
  103. package/src/renderers/complex/TIMELINE.md +0 -353
  104. package/src/renderers/complex/__tests__/data-table-airtable-ux.test.tsx +0 -239
  105. package/src/renderers/complex/__tests__/data-table-batch-editing.test.tsx +0 -275
  106. package/src/renderers/complex/__tests__/data-table-cell-renderer.test.tsx +0 -120
  107. package/src/renderers/complex/__tests__/data-table-editing.test.tsx +0 -221
  108. package/src/renderers/complex/__tests__/data-table.test.ts +0 -76
  109. package/src/renderers/complex/carousel.tsx +0 -69
  110. package/src/renderers/complex/data-table.tsx +0 -1243
  111. package/src/renderers/complex/filter-builder.tsx +0 -77
  112. package/src/renderers/complex/index.ts +0 -16
  113. package/src/renderers/complex/resizable.tsx +0 -66
  114. package/src/renderers/complex/scroll-area.tsx +0 -58
  115. package/src/renderers/complex/table.tsx +0 -95
  116. package/src/renderers/data-display/alert.tsx +0 -46
  117. package/src/renderers/data-display/avatar.tsx +0 -38
  118. package/src/renderers/data-display/badge.tsx +0 -55
  119. package/src/renderers/data-display/breadcrumb.tsx +0 -61
  120. package/src/renderers/data-display/index.ts +0 -18
  121. package/src/renderers/data-display/kbd.tsx +0 -50
  122. package/src/renderers/data-display/list.tsx +0 -75
  123. package/src/renderers/data-display/statistic.tsx +0 -95
  124. package/src/renderers/data-display/table.tsx +0 -78
  125. package/src/renderers/data-display/tree-view.tsx +0 -176
  126. package/src/renderers/disclosure/accordion.tsx +0 -69
  127. package/src/renderers/disclosure/collapsible.tsx +0 -53
  128. package/src/renderers/disclosure/index.ts +0 -11
  129. package/src/renderers/disclosure/toggle-group.tsx +0 -79
  130. package/src/renderers/feedback/empty.tsx +0 -49
  131. package/src/renderers/feedback/index.ts +0 -16
  132. package/src/renderers/feedback/loading.tsx +0 -78
  133. package/src/renderers/feedback/progress.tsx +0 -29
  134. package/src/renderers/feedback/skeleton.tsx +0 -31
  135. package/src/renderers/feedback/sonner.tsx +0 -56
  136. package/src/renderers/feedback/spinner.tsx +0 -55
  137. package/src/renderers/feedback/toast.tsx +0 -59
  138. package/src/renderers/feedback/toaster.tsx +0 -23
  139. package/src/renderers/form/button.tsx +0 -103
  140. package/src/renderers/form/calendar.tsx +0 -34
  141. package/src/renderers/form/checkbox.tsx +0 -71
  142. package/src/renderers/form/combobox.tsx +0 -48
  143. package/src/renderers/form/command.tsx +0 -58
  144. package/src/renderers/form/date-picker.tsx +0 -84
  145. package/src/renderers/form/file-upload.tsx +0 -184
  146. package/src/renderers/form/form.tsx +0 -540
  147. package/src/renderers/form/index.ts +0 -26
  148. package/src/renderers/form/input-otp.tsx +0 -51
  149. package/src/renderers/form/input.tsx +0 -121
  150. package/src/renderers/form/label.tsx +0 -45
  151. package/src/renderers/form/radio-group.tsx +0 -63
  152. package/src/renderers/form/select.tsx +0 -94
  153. package/src/renderers/form/slider.tsx +0 -61
  154. package/src/renderers/form/switch.tsx +0 -48
  155. package/src/renderers/form/textarea.tsx +0 -76
  156. package/src/renderers/form/toggle.tsx +0 -42
  157. package/src/renderers/index.ts +0 -18
  158. package/src/renderers/layout/aspect-ratio.tsx +0 -51
  159. package/src/renderers/layout/card.tsx +0 -85
  160. package/src/renderers/layout/container.tsx +0 -122
  161. package/src/renderers/layout/flex.tsx +0 -132
  162. package/src/renderers/layout/grid.tsx +0 -178
  163. package/src/renderers/layout/index.ts +0 -19
  164. package/src/renderers/layout/page.tsx +0 -466
  165. package/src/renderers/layout/semantic.tsx +0 -48
  166. package/src/renderers/layout/stack.tsx +0 -132
  167. package/src/renderers/layout/tabs.tsx +0 -97
  168. package/src/renderers/navigation/header-bar.tsx +0 -118
  169. package/src/renderers/navigation/index.ts +0 -10
  170. package/src/renderers/navigation/sidebar.tsx +0 -208
  171. package/src/renderers/overlay/alert-dialog.tsx +0 -72
  172. package/src/renderers/overlay/context-menu.tsx +0 -100
  173. package/src/renderers/overlay/dialog.tsx +0 -77
  174. package/src/renderers/overlay/drawer.tsx +0 -77
  175. package/src/renderers/overlay/dropdown-menu.tsx +0 -99
  176. package/src/renderers/overlay/hover-card.tsx +0 -55
  177. package/src/renderers/overlay/index.ts +0 -18
  178. package/src/renderers/overlay/menubar.tsx +0 -76
  179. package/src/renderers/overlay/popover.tsx +0 -56
  180. package/src/renderers/overlay/sheet.tsx +0 -77
  181. package/src/renderers/overlay/tooltip.tsx +0 -67
  182. package/src/renderers/placeholders.tsx +0 -107
  183. package/src/stories/CRMApp.stories.tsx +0 -706
  184. package/src/stories/ConfigPanel.stories.tsx +0 -232
  185. package/src/stories/Guide.mdx +0 -55
  186. package/src/stories/MockedData.stories.tsx +0 -121
  187. package/src/stories/assets/accessibility.png +0 -0
  188. package/src/stories/assets/accessibility.svg +0 -1
  189. package/src/stories/assets/addon-library.png +0 -0
  190. package/src/stories/assets/assets.png +0 -0
  191. package/src/stories/assets/avif-test-image.avif +0 -0
  192. package/src/stories/assets/context.png +0 -0
  193. package/src/stories/assets/discord.svg +0 -1
  194. package/src/stories/assets/docs.png +0 -0
  195. package/src/stories/assets/figma-plugin.png +0 -0
  196. package/src/stories/assets/github.svg +0 -1
  197. package/src/stories/assets/share.png +0 -0
  198. package/src/stories/assets/styling.png +0 -0
  199. package/src/stories/assets/testing.png +0 -0
  200. package/src/stories/assets/theming.png +0 -0
  201. package/src/stories/assets/tutorials.svg +0 -1
  202. package/src/stories/assets/youtube.svg +0 -1
  203. package/src/stories/button.css +0 -30
  204. package/src/stories/header.css +0 -32
  205. package/src/stories/page.css +0 -68
  206. package/src/stories-json/Accessibility.mdx +0 -297
  207. package/src/stories-json/EdgeCases.stories.tsx +0 -160
  208. package/src/stories-json/GettingStarted.mdx +0 -89
  209. package/src/stories-json/Introduction.mdx +0 -127
  210. package/src/stories-json/accordion.stories.tsx +0 -43
  211. package/src/stories-json/aggrid.stories.tsx +0 -103
  212. package/src/stories-json/alert.stories.tsx +0 -39
  213. package/src/stories-json/aspect-ratio.stories.tsx +0 -34
  214. package/src/stories-json/avatar.stories.tsx +0 -38
  215. package/src/stories-json/badge.stories.tsx +0 -53
  216. package/src/stories-json/breadcrumb.stories.tsx +0 -30
  217. package/src/stories-json/button-group.stories.tsx +0 -43
  218. package/src/stories-json/button.stories.tsx +0 -73
  219. package/src/stories-json/calendar.stories.tsx +0 -85
  220. package/src/stories-json/card.stories.tsx +0 -48
  221. package/src/stories-json/carousel.stories.tsx +0 -33
  222. package/src/stories-json/charts.stories.tsx +0 -195
  223. package/src/stories-json/chatbot.stories.tsx +0 -349
  224. package/src/stories-json/code-editor.stories.tsx +0 -92
  225. package/src/stories-json/collapsible.stories.tsx +0 -40
  226. package/src/stories-json/controls.stories.tsx +0 -36
  227. package/src/stories-json/crm-live-data.stories.tsx +0 -154
  228. package/src/stories-json/dashboard.stories.tsx +0 -318
  229. package/src/stories-json/data-table.stories.tsx +0 -136
  230. package/src/stories-json/data_display_extras.stories.tsx +0 -102
  231. package/src/stories-json/date-picker.stories.tsx +0 -28
  232. package/src/stories-json/detail-view.stories.tsx +0 -258
  233. package/src/stories-json/dialog.stories.tsx +0 -43
  234. package/src/stories-json/feedback_extras.stories.tsx +0 -40
  235. package/src/stories-json/feedback_others.stories.tsx +0 -46
  236. package/src/stories-json/form-variants.stories.tsx +0 -210
  237. package/src/stories-json/form_advanced.stories.tsx +0 -117
  238. package/src/stories-json/form_extras.stories.tsx +0 -123
  239. package/src/stories-json/grid.stories.tsx +0 -56
  240. package/src/stories-json/icon.stories.tsx +0 -36
  241. package/src/stories-json/input.stories.tsx +0 -52
  242. package/src/stories-json/kanban.stories.tsx +0 -295
  243. package/src/stories-json/layout_extended.stories.tsx +0 -76
  244. package/src/stories-json/layout_flex.stories.tsx +0 -107
  245. package/src/stories-json/list-view.stories.tsx +0 -97
  246. package/src/stories-json/markdown.stories.tsx +0 -129
  247. package/src/stories-json/menus.stories.tsx +0 -63
  248. package/src/stories-json/metric-card.stories.tsx +0 -143
  249. package/src/stories-json/navigation-menu.stories.tsx +0 -37
  250. package/src/stories-json/object-aggrid-advanced.stories.tsx +0 -389
  251. package/src/stories-json/object-aggrid.stories.tsx +0 -252
  252. package/src/stories-json/object-form.stories.tsx +0 -130
  253. package/src/stories-json/object-gantt.stories.tsx +0 -114
  254. package/src/stories-json/object-grid.stories.tsx +0 -315
  255. package/src/stories-json/object-map.stories.tsx +0 -116
  256. package/src/stories-json/object-view.stories.tsx +0 -118
  257. package/src/stories-json/overlay_extras.stories.tsx +0 -113
  258. package/src/stories-json/overlay_others.stories.tsx +0 -76
  259. package/src/stories-json/page.stories.tsx +0 -55
  260. package/src/stories-json/reports.stories.tsx +0 -163
  261. package/src/stories-json/resizable.stories.tsx +0 -44
  262. package/src/stories-json/select.stories.tsx +0 -34
  263. package/src/stories-json/separator.stories.tsx +0 -41
  264. package/src/stories-json/sidebar.stories.tsx +0 -147
  265. package/src/stories-json/statistic.stories.tsx +0 -44
  266. package/src/stories-json/tabs.stories.tsx +0 -51
  267. package/src/stories-json/timeline.stories.tsx +0 -188
  268. package/src/stories-json/typography.stories.tsx +0 -45
  269. package/src/types/config-panel.ts +0 -101
  270. package/src/ui/accordion.tsx +0 -66
  271. package/src/ui/alert-dialog.tsx +0 -149
  272. package/src/ui/alert.tsx +0 -67
  273. package/src/ui/aspect-ratio.tsx +0 -15
  274. package/src/ui/avatar.tsx +0 -58
  275. package/src/ui/badge.tsx +0 -44
  276. package/src/ui/breadcrumb.tsx +0 -123
  277. package/src/ui/button.tsx +0 -64
  278. package/src/ui/calendar.tsx +0 -221
  279. package/src/ui/card.tsx +0 -87
  280. package/src/ui/carousel.tsx +0 -270
  281. package/src/ui/chart.tsx +0 -377
  282. package/src/ui/checkbox.tsx +0 -38
  283. package/src/ui/collapsible.tsx +0 -19
  284. package/src/ui/command.tsx +0 -161
  285. package/src/ui/context-menu.tsx +0 -208
  286. package/src/ui/dialog.tsx +0 -130
  287. package/src/ui/drawer.tsx +0 -126
  288. package/src/ui/dropdown-menu.tsx +0 -208
  289. package/src/ui/form.tsx +0 -186
  290. package/src/ui/hover-card.tsx +0 -37
  291. package/src/ui/index.ts +0 -56
  292. package/src/ui/input-otp.tsx +0 -79
  293. package/src/ui/input.tsx +0 -30
  294. package/src/ui/label.tsx +0 -34
  295. package/src/ui/menubar.tsx +0 -264
  296. package/src/ui/navigation-menu.tsx +0 -136
  297. package/src/ui/pagination.tsx +0 -125
  298. package/src/ui/popover.tsx +0 -39
  299. package/src/ui/progress.tsx +0 -36
  300. package/src/ui/radio-group.tsx +0 -52
  301. package/src/ui/resizable.tsx +0 -53
  302. package/src/ui/scroll-area.tsx +0 -56
  303. package/src/ui/select.tsx +0 -168
  304. package/src/ui/separator.tsx +0 -39
  305. package/src/ui/sheet.tsx +0 -150
  306. package/src/ui/sidebar.tsx +0 -781
  307. package/src/ui/skeleton.tsx +0 -23
  308. package/src/ui/slider.tsx +0 -39
  309. package/src/ui/sonner.tsx +0 -53
  310. package/src/ui/switch.tsx +0 -37
  311. package/src/ui/table.tsx +0 -125
  312. package/src/ui/tabs.tsx +0 -63
  313. package/src/ui/textarea.tsx +0 -30
  314. package/src/ui/toast.tsx +0 -137
  315. package/src/ui/toggle-group.tsx +0 -69
  316. package/src/ui/toggle.tsx +0 -53
  317. package/src/ui/tooltip.tsx +0 -38
  318. package/src/ui/typography.tsx +0 -85
  319. package/tsconfig.json +0 -19
  320. package/vite.config.ts +0 -71
  321. package/vitest.config.ts +0 -5
@@ -1,1243 +0,0 @@
1
- /**
2
- * ObjectUI
3
- * Copyright (c) 2024-present ObjectStack Inc.
4
- *
5
- * This source code is licensed under the MIT license found in the
6
- * LICENSE file in the root directory of this source tree.
7
- */
8
-
9
- // Enterprise-level DataTable Component (Airtable-like)
10
- import React, { useState, useMemo, useRef, useEffect } from 'react';
11
- import { cn } from '../../lib/utils';
12
- import { ComponentRegistry } from '@object-ui/core';
13
- import type { DataTableSchema } from '@object-ui/types';
14
- import { useObjectTranslation } from '@object-ui/react';
15
- import {
16
- Table,
17
- TableHeader,
18
- TableBody,
19
- TableFooter,
20
- TableHead,
21
- TableRow,
22
- TableCell,
23
- TableCaption
24
- } from '../../ui/table';
25
- import { Button } from '../../ui/button';
26
- import { Input } from '../../ui/input';
27
- import { Checkbox } from '../../ui/checkbox';
28
- import {
29
- Select,
30
- SelectContent,
31
- SelectItem,
32
- SelectTrigger,
33
- SelectValue,
34
- } from '../../ui/select';
35
- import {
36
- ChevronUp,
37
- ChevronDown,
38
- ChevronsUpDown,
39
- Search,
40
- Download,
41
- Edit,
42
- Trash2,
43
- ChevronLeft,
44
- ChevronRight,
45
- ChevronsLeft,
46
- ChevronsRight,
47
- GripVertical,
48
- Save,
49
- X,
50
- Plus,
51
- Expand,
52
- } from 'lucide-react';
53
-
54
- type SortDirection = 'asc' | 'desc' | null;
55
-
56
- /** Number of skeleton rows shown when the table has no data */
57
- const GHOST_ROW_COUNT = 3;
58
-
59
- /** Returns a Tailwind width class for ghost cell placeholders to create visual variety */
60
- function ghostCellWidth(columnIndex: number, totalColumns: number): string {
61
- if (columnIndex === 0) return 'w-3/4';
62
- if (columnIndex === totalColumns - 1) return 'w-1/3';
63
- return 'w-1/2';
64
- }
65
-
66
- // Default English fallback translations for the data table
67
- const TABLE_DEFAULT_TRANSLATIONS: Record<string, string> = {
68
- 'table.rowsPerPage': 'Rows per page',
69
- 'table.pageInfo': 'Page {{current}} of {{total}}',
70
- 'table.totalRecords': '{{count}} total',
71
- 'table.noResults': 'No results found',
72
- 'table.noResultsHint': 'Try adjusting your filters or search query.',
73
- 'table.sortAsc': 'Sort ascending',
74
- 'table.sortDesc': 'Sort descending',
75
- 'table.hideColumn': 'Hide column',
76
- 'table.cancelAll': 'Cancel All',
77
- 'table.saveAll': 'Save All ({{count}})',
78
- 'table.exportCSV': 'Export CSV',
79
- 'table.addRecord': 'Add record',
80
- 'table.open': 'Open',
81
- 'table.search': 'Search...',
82
- 'table.modified': '{{count}} row modified',
83
- 'table.selected': '{{count}} selected',
84
- 'common.actions': 'Actions',
85
- };
86
-
87
- /**
88
- * Safe wrapper for useObjectTranslation that falls back to English defaults
89
- * when I18nProvider is not available (e.g., standalone usage).
90
- */
91
- function useTableTranslation() {
92
- try {
93
- const result = useObjectTranslation();
94
- const testValue = result.t('table.rowsPerPage');
95
- if (testValue === 'table.rowsPerPage') {
96
- return {
97
- t: (key: string, options?: Record<string, unknown>) => {
98
- let value = TABLE_DEFAULT_TRANSLATIONS[key] || key;
99
- if (options) {
100
- for (const [k, v] of Object.entries(options)) {
101
- value = value.replace(`{{${k}}}`, String(v));
102
- }
103
- }
104
- return value;
105
- },
106
- };
107
- }
108
- return { t: result.t };
109
- } catch {
110
- return {
111
- t: (key: string, options?: Record<string, unknown>) => {
112
- let value = TABLE_DEFAULT_TRANSLATIONS[key] || key;
113
- if (options) {
114
- for (const [k, v] of Object.entries(options)) {
115
- value = value.replace(`{{${k}}}`, String(v));
116
- }
117
- }
118
- return value;
119
- },
120
- };
121
- }
122
- }
123
-
124
- /**
125
- * Enterprise-level data table component with Airtable-like features.
126
- *
127
- * Provides comprehensive table functionality including:
128
- * - Multi-column sorting (ascending/descending/none)
129
- * - Real-time search across all columns
130
- * - Pagination with configurable page sizes
131
- * - Row selection with persistence across pages
132
- * - CSV export of filtered/sorted data
133
- * - Row action buttons (edit/delete)
134
- *
135
- * @example
136
- * ```json
137
- * {
138
- * "type": "data-table",
139
- * "pagination": true,
140
- * "searchable": true,
141
- * "selectable": true,
142
- * "sortable": true,
143
- * "exportable": true,
144
- * "rowActions": true,
145
- * "columns": [
146
- * { "header": "ID", "accessorKey": "id", "width": "80px" },
147
- * { "header": "Name", "accessorKey": "name" }
148
- * ],
149
- * "data": [
150
- * { "id": 1, "name": "John Doe" }
151
- * ]
152
- * }
153
- * ```
154
- *
155
- * @param {Object} props - Component props
156
- * @param {DataTableSchema} props.schema - Table schema configuration
157
- * @returns {JSX.Element} Rendered data table component
158
- */
159
- const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
160
- const {
161
- caption,
162
- columns: rawColumns = [],
163
- data: rawData = [],
164
- pagination = true,
165
- pageSize: initialPageSize = 10,
166
- searchable = true,
167
- selectable = false,
168
- sortable = true,
169
- exportable = false,
170
- rowActions = false,
171
- resizableColumns = true,
172
- reorderableColumns = true,
173
- editable = false,
174
- singleClickEdit = false,
175
- selectionStyle = 'always',
176
- rowClassName,
177
- rowStyle,
178
- className,
179
- frozenColumns = 0,
180
- showRowNumbers = false,
181
- showAddRow = false,
182
- } = schema;
183
-
184
- // i18n support for pagination labels
185
- const { t } = useTableTranslation();
186
-
187
- // Ensure data is always an array – provider config objects or null/undefined
188
- // must not reach array operations like .filter() / .some()
189
- const data = Array.isArray(rawData) ? rawData : [];
190
-
191
- // Normalize columns to support legacy keys (label/name) from existing JSONs
192
- const initialColumns = useMemo(() => {
193
- return rawColumns.map((col: any) => ({
194
- ...col,
195
- header: col.header || col.label,
196
- accessorKey: col.accessorKey || col.name
197
- }));
198
- }, [rawColumns]);
199
-
200
- // Auto-size columns: estimate width from header and data content for columns without explicit widths
201
- const autoSizedWidths = useMemo(() => {
202
- const widths: Record<string, number> = {};
203
- const cols = rawColumns.map((col: any) => ({
204
- header: col.header || col.label,
205
- accessorKey: col.accessorKey || col.name,
206
- width: col.width,
207
- }));
208
- for (const col of cols) {
209
- if (col.width) continue; // Skip columns with explicit widths
210
- const headerLen = (col.header || '').length;
211
- let maxLen = headerLen;
212
- // Sample up to 50 rows for content width estimation
213
- const sampleRows = data.slice(0, 50);
214
- for (const row of sampleRows) {
215
- const val = row[col.accessorKey];
216
- const len = val != null ? String(val).length : 0;
217
- if (len > maxLen) maxLen = len;
218
- }
219
- // Estimate pixel width: ~8px per character + 48px padding, min 80, max 400
220
- widths[col.accessorKey] = Math.min(400, Math.max(80, maxLen * 8 + 48));
221
- }
222
- return widths;
223
- }, [rawColumns, data]);
224
-
225
- // State management
226
- const [searchQuery, setSearchQuery] = useState('');
227
- const [sortColumn, setSortColumn] = useState<string | null>(null);
228
- const [sortDirection, setSortDirection] = useState<SortDirection>(null);
229
- const [selectedRowIds, setSelectedRowIds] = useState<Set<any>>(new Set());
230
- const [currentPage, setCurrentPage] = useState(1);
231
- const [pageSize, setPageSize] = useState(initialPageSize);
232
- const [columns, setColumns] = useState(initialColumns);
233
- const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
234
- const [draggedColumn, setDraggedColumn] = useState<number | null>(null);
235
- const [dragOverColumn, setDragOverColumn] = useState<number | null>(null);
236
- const [editingCell, setEditingCell] = useState<{ rowIndex: number; columnKey: string } | null>(null);
237
- const [editValue, setEditValue] = useState<any>('');
238
- // Track pending changes for multi-cell editing: rowIndex -> { columnKey -> newValue }
239
- const [pendingChanges, setPendingChanges] = useState<Map<number, Record<string, any>>>(new Map());
240
- const [isSaving, setIsSaving] = useState(false);
241
- // Column header context menu state
242
- const [contextMenu, setContextMenu] = useState<{ x: number; y: number; columnKey: string } | null>(null);
243
-
244
- // Refs for column resizing
245
- const resizingColumn = useRef<string | null>(null);
246
- const startX = useRef<number>(0);
247
- const startWidth = useRef<number>(0);
248
- const editInputRef = useRef<HTMLInputElement>(null);
249
-
250
- // Update columns when schema changes
251
- useEffect(() => {
252
- setColumns(initialColumns);
253
- }, [initialColumns]);
254
-
255
- // Filtering
256
- const filteredData = useMemo(() => {
257
- if (!searchQuery) return data;
258
-
259
- return data.filter((row) =>
260
- columns.some((col) => {
261
- const value = row[col.accessorKey];
262
- return value?.toString().toLowerCase().includes(searchQuery.toLowerCase());
263
- })
264
- );
265
- }, [data, searchQuery, columns]);
266
-
267
- // Sorting
268
- const sortedData = useMemo(() => {
269
- if (!sortColumn || !sortDirection) return filteredData;
270
-
271
- return [...filteredData].sort((a, b) => {
272
- const aValue = a[sortColumn];
273
- const bValue = b[sortColumn];
274
-
275
- if (aValue === bValue) return 0;
276
-
277
- const comparison = aValue < bValue ? -1 : 1;
278
- return sortDirection === 'asc' ? comparison : -comparison;
279
- });
280
- }, [filteredData, sortColumn, sortDirection]);
281
-
282
- // Pagination
283
- const totalPages = Math.ceil(sortedData.length / pageSize);
284
- const paginatedData = pagination
285
- ? sortedData.slice((currentPage - 1) * pageSize, currentPage * pageSize)
286
- : sortedData;
287
-
288
- /**
289
- * Generates a unique identifier for each row to maintain stable selection state
290
- * across pagination and sorting operations.
291
- *
292
- * @param {any} row - The data row object
293
- * @param {number} index - The row's index in the dataset
294
- * @returns {string | number} Unique row identifier (uses 'id' field if available, falls back to index)
295
- */
296
- const getRowId = (row: any, index: number) => {
297
- // Try to use 'id' field, fall back to index
298
- return row.id !== undefined ? row.id : `row-${index}`;
299
- };
300
-
301
- // Handlers
302
- const handleSort = (columnKey: string) => {
303
- if (!sortable) return;
304
-
305
- if (sortColumn === columnKey) {
306
- if (sortDirection === 'asc') {
307
- setSortDirection('desc');
308
- } else if (sortDirection === 'desc') {
309
- setSortDirection(null);
310
- setSortColumn(null);
311
- }
312
- } else {
313
- setSortColumn(columnKey);
314
- setSortDirection('asc');
315
- }
316
- };
317
-
318
- // Column header context menu handler
319
- const handleColumnContextMenu = (e: React.MouseEvent, columnKey: string) => {
320
- e.preventDefault();
321
- setContextMenu({ x: e.clientX, y: e.clientY, columnKey });
322
- };
323
-
324
- const hideColumn = (columnKey: string) => {
325
- setColumns(prev => prev.filter(c => c.accessorKey !== columnKey));
326
- setContextMenu(null);
327
- };
328
-
329
- // Close context menu on outside click
330
- useEffect(() => {
331
- if (!contextMenu) return;
332
- const close = () => setContextMenu(null);
333
- document.addEventListener('click', close);
334
- return () => document.removeEventListener('click', close);
335
- }, [contextMenu]);
336
-
337
- const handleSelectAll = (checked: boolean) => {
338
- const newSelected = new Set<any>();
339
- if (checked) {
340
- paginatedData.forEach((row, idx) => {
341
- const globalIndex = (currentPage - 1) * pageSize + idx;
342
- const rowId = getRowId(row, globalIndex);
343
- newSelected.add(rowId);
344
- });
345
- }
346
- setSelectedRowIds(newSelected);
347
-
348
- // Call callback if provided
349
- if (schema.onSelectionChange) {
350
- const selectedData = sortedData.filter((row, idx) => {
351
- const rowId = getRowId(row, idx);
352
- return newSelected.has(rowId);
353
- });
354
- schema.onSelectionChange(selectedData);
355
- }
356
- };
357
-
358
- const handleSelectRow = (rowId: any, checked: boolean) => {
359
- const newSelected = new Set(selectedRowIds);
360
- if (checked) {
361
- newSelected.add(rowId);
362
- } else {
363
- newSelected.delete(rowId);
364
- }
365
- setSelectedRowIds(newSelected);
366
-
367
- // Call callback if provided
368
- if (schema.onSelectionChange) {
369
- const selectedData = sortedData.filter((row, idx) => {
370
- const id = getRowId(row, idx);
371
- return newSelected.has(id);
372
- });
373
- schema.onSelectionChange(selectedData);
374
- }
375
- };
376
-
377
- const handleExport = () => {
378
- const csvContent = [
379
- columns.map(col => col.header).join(','),
380
- ...sortedData.map(row =>
381
- columns.map(col => JSON.stringify(row[col.accessorKey] || '')).join(',')
382
- )
383
- ].join('\n');
384
-
385
- const blob = new Blob([csvContent], { type: 'text/csv' });
386
- const url = window.URL.createObjectURL(blob);
387
- const a = document.createElement('a');
388
- a.href = url;
389
- a.download = 'table-export.csv';
390
- a.click();
391
- window.URL.revokeObjectURL(url);
392
- };
393
-
394
- const getSortIcon = (columnKey: string) => {
395
- if (sortColumn !== columnKey) {
396
- return <ChevronsUpDown className="h-3 w-3 ml-0.5 opacity-0 group-hover:opacity-50 transition-opacity" />;
397
- }
398
- if (sortDirection === 'asc') {
399
- return <ChevronUp className="h-3 w-3 ml-0.5 text-primary" />;
400
- }
401
- return <ChevronDown className="h-3 w-3 ml-0.5 text-primary" />;
402
- };
403
-
404
- // Column resizing handlers
405
- const handleResizeStart = (e: React.MouseEvent, columnKey: string) => {
406
- if (!resizableColumns) return;
407
- e.preventDefault();
408
- e.stopPropagation();
409
-
410
- resizingColumn.current = columnKey;
411
- startX.current = e.clientX;
412
-
413
- const headerCell = (e.target as HTMLElement).closest('th');
414
- if (headerCell) {
415
- startWidth.current = headerCell.offsetWidth;
416
- }
417
-
418
- document.addEventListener('mousemove', handleResizeMove);
419
- document.addEventListener('mouseup', handleResizeEnd);
420
- };
421
-
422
- const handleResizeMove = (e: MouseEvent) => {
423
- if (!resizingColumn.current) return;
424
-
425
- const diff = e.clientX - startX.current;
426
- const newWidth = Math.max(50, startWidth.current + diff); // Min width 50px
427
-
428
- setColumnWidths(prev => ({
429
- ...prev,
430
- [resizingColumn.current!]: newWidth
431
- }));
432
- };
433
-
434
- const handleResizeEnd = () => {
435
- resizingColumn.current = null;
436
- document.removeEventListener('mousemove', handleResizeMove);
437
- document.removeEventListener('mouseup', handleResizeEnd);
438
- };
439
-
440
- // Column reordering handlers
441
- const handleColumnDragStart = (e: React.DragEvent, index: number) => {
442
- if (!reorderableColumns) return;
443
- setDraggedColumn(index);
444
- e.dataTransfer.effectAllowed = 'move';
445
- };
446
-
447
- const handleColumnDragOver = (e: React.DragEvent, index: number) => {
448
- if (!reorderableColumns) return;
449
- e.preventDefault();
450
- e.dataTransfer.dropEffect = 'move';
451
- setDragOverColumn(index);
452
- };
453
-
454
- const handleColumnDrop = (e: React.DragEvent, dropIndex: number) => {
455
- if (!reorderableColumns || draggedColumn === null) return;
456
- e.preventDefault();
457
-
458
- if (draggedColumn === dropIndex) {
459
- setDraggedColumn(null);
460
- setDragOverColumn(null);
461
- return;
462
- }
463
-
464
- const newColumns = [...columns];
465
- const [removed] = newColumns.splice(draggedColumn, 1);
466
- newColumns.splice(dropIndex, 0, removed);
467
-
468
- setColumns(newColumns);
469
- setDraggedColumn(null);
470
- setDragOverColumn(null);
471
-
472
- // Call callback if provided
473
- if (schema.onColumnsReorder) {
474
- schema.onColumnsReorder(newColumns);
475
- }
476
- };
477
-
478
- const handleColumnDragEnd = () => {
479
- setDraggedColumn(null);
480
- setDragOverColumn(null);
481
- };
482
-
483
- // Cell editing handlers
484
- const startEdit = (rowIndex: number, columnKey: string) => {
485
- if (!editable) return;
486
-
487
- const column = columns.find(col => col.accessorKey === columnKey);
488
- if (column?.editable === false) return;
489
-
490
- setEditingCell({ rowIndex, columnKey });
491
-
492
- // Check if there's a pending change for this cell, otherwise use current data value
493
- const rowChanges = pendingChanges.get(rowIndex);
494
- const currentValue = paginatedData[rowIndex][columnKey];
495
- const valueToEdit = rowChanges?.[columnKey] ?? currentValue ?? '';
496
- setEditValue(valueToEdit);
497
- };
498
-
499
- const saveEdit = (force: boolean = false) => {
500
- if (!editingCell) return;
501
-
502
- // Don't save if we're in cancelled state (unless forced)
503
- if (!force && editingCell === null) return;
504
-
505
- const { rowIndex, columnKey } = editingCell;
506
- const globalIndex = (currentPage - 1) * pageSize + rowIndex;
507
- const row = sortedData[globalIndex];
508
-
509
- // Update pending changes
510
- const newPendingChanges = new Map(pendingChanges);
511
- const rowChanges = newPendingChanges.get(rowIndex) || {};
512
- rowChanges[columnKey] = editValue;
513
- newPendingChanges.set(rowIndex, rowChanges);
514
- setPendingChanges(newPendingChanges);
515
-
516
- // Call the legacy onCellChange callback if provided
517
- if (schema.onCellChange) {
518
- schema.onCellChange(globalIndex, columnKey, editValue, row);
519
- }
520
-
521
- setEditingCell(null);
522
- setEditValue('');
523
- };
524
-
525
- const cancelEdit = () => {
526
- setEditingCell(null);
527
- setEditValue('');
528
- };
529
-
530
- const saveRow = async (rowIndex: number) => {
531
- const globalIndex = (currentPage - 1) * pageSize + rowIndex;
532
- const row = sortedData[globalIndex];
533
- const rowChanges = pendingChanges.get(rowIndex);
534
-
535
- if (!rowChanges || Object.keys(rowChanges).length === 0) return;
536
-
537
- setIsSaving(true);
538
- try {
539
- if (schema.onRowSave) {
540
- await schema.onRowSave(globalIndex, rowChanges, row);
541
- }
542
-
543
- // Clear pending changes for this row
544
- const newPendingChanges = new Map(pendingChanges);
545
- newPendingChanges.delete(rowIndex);
546
- setPendingChanges(newPendingChanges);
547
- } catch (error) {
548
- console.error('Failed to save row:', error);
549
- } finally {
550
- setIsSaving(false);
551
- }
552
- };
553
-
554
- const cancelRowChanges = (rowIndex: number) => {
555
- const newPendingChanges = new Map(pendingChanges);
556
- newPendingChanges.delete(rowIndex);
557
- setPendingChanges(newPendingChanges);
558
- };
559
-
560
- const saveBatch = async () => {
561
- if (pendingChanges.size === 0) return;
562
-
563
- setIsSaving(true);
564
- try {
565
- const changesToSave = Array.from(pendingChanges.entries()).map(([rowIndex, changes]) => {
566
- const globalIndex = (currentPage - 1) * pageSize + rowIndex;
567
- const row = sortedData[globalIndex];
568
- return { rowIndex: globalIndex, changes, row };
569
- });
570
-
571
- if (schema.onBatchSave) {
572
- await schema.onBatchSave(changesToSave);
573
- }
574
-
575
- // Clear all pending changes
576
- setPendingChanges(new Map());
577
- } catch (error) {
578
- console.error('Failed to save batch:', error);
579
- } finally {
580
- setIsSaving(false);
581
- }
582
- };
583
-
584
- const cancelAllChanges = () => {
585
- setPendingChanges(new Map());
586
- };
587
-
588
- const handleCellKeyDown = (e: React.KeyboardEvent, rowIndex: number, columnKey: string) => {
589
- // Copy cell value with Ctrl+C / Cmd+C
590
- if ((e.ctrlKey || e.metaKey) && e.key === 'c' && !editingCell) {
591
- e.preventDefault();
592
- const globalIdx = (currentPage - 1) * pageSize + rowIndex;
593
- const row = sortedData[globalIdx];
594
- if (row) {
595
- const value = row[columnKey];
596
- const text = value != null ? String(value) : '';
597
- navigator.clipboard.writeText(text).catch(() => {
598
- // Fallback for environments without clipboard API
599
- });
600
- }
601
- return;
602
- }
603
-
604
- if (!editable) return;
605
-
606
- const column = columns.find(col => col.accessorKey === columnKey);
607
- if (column?.editable === false) return;
608
-
609
- if (e.key === 'Enter' && !editingCell) {
610
- e.preventDefault();
611
- startEdit(rowIndex, columnKey);
612
- }
613
- };
614
-
615
- const handleEditKeyDown = (e: React.KeyboardEvent) => {
616
- if (e.key === 'Enter') {
617
- e.preventDefault();
618
- saveEdit(true);
619
- } else if (e.key === 'Escape') {
620
- e.preventDefault();
621
- cancelEdit();
622
- }
623
- };
624
-
625
- // Auto-focus on edit input when entering edit mode
626
- useEffect(() => {
627
- if (editingCell && editInputRef.current) {
628
- editInputRef.current.focus();
629
- editInputRef.current.select();
630
- }
631
- }, [editingCell]);
632
-
633
- // Cleanup on unmount
634
- useEffect(() => {
635
- return () => {
636
- document.removeEventListener('mousemove', handleResizeMove);
637
- document.removeEventListener('mouseup', handleResizeEnd);
638
- };
639
- }, []);
640
-
641
- // Check if all rows on current page are selected
642
- const allPageRowsSelected = paginatedData.length > 0 && paginatedData.every((row, idx) => {
643
- const globalIndex = (currentPage - 1) * pageSize + idx;
644
- const rowId = getRowId(row, globalIndex);
645
- return selectedRowIds.has(rowId);
646
- });
647
-
648
- const somePageRowsSelected = paginatedData.some((row, idx) => {
649
- const globalIndex = (currentPage - 1) * pageSize + idx;
650
- const rowId = getRowId(row, globalIndex);
651
- return selectedRowIds.has(rowId);
652
- }) && !allPageRowsSelected;
653
-
654
- const hasPendingChanges = pendingChanges.size > 0;
655
- const showToolbar = searchable || exportable || (selectable && selectedRowIds.size > 0) || hasPendingChanges;
656
-
657
- return (
658
- <div className={`flex flex-col h-full gap-2 sm:gap-4 ${className || ''}`}>
659
- {/* Toolbar */}
660
- {showToolbar && (
661
- <div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 sm:gap-4 flex-none">
662
- <div className="flex items-center gap-2 flex-1">
663
- {searchable && (
664
- <div className="relative w-full sm:max-w-sm flex-1">
665
- <Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
666
- <Input
667
- placeholder={t('table.search')}
668
- value={searchQuery}
669
- onChange={(e) => {
670
- setSearchQuery(e.target.value);
671
- setCurrentPage(1);
672
- }}
673
- className="pl-8"
674
- />
675
- </div>
676
- )}
677
- </div>
678
-
679
- <div className="flex flex-wrap items-center gap-2">
680
- {hasPendingChanges && (
681
- <>
682
- <div className="text-sm text-muted-foreground">
683
- {t('table.modified', { count: pendingChanges.size })}
684
- </div>
685
- <Button
686
- variant="outline"
687
- size="sm"
688
- onClick={cancelAllChanges}
689
- disabled={isSaving}
690
- >
691
- <X className="h-4 w-4 mr-2" />
692
- {t('table.cancelAll')}
693
- </Button>
694
- <Button
695
- variant="default"
696
- size="sm"
697
- onClick={saveBatch}
698
- disabled={isSaving}
699
- >
700
- <Save className="h-4 w-4 mr-2" />
701
- {t('table.saveAll', { count: pendingChanges.size })}
702
- </Button>
703
- </>
704
- )}
705
-
706
- {exportable && (
707
- <Button
708
- variant="outline"
709
- size="sm"
710
- onClick={handleExport}
711
- disabled={sortedData.length === 0}
712
- >
713
- <Download className="h-4 w-4 mr-2" />
714
- {t('table.exportCSV')}
715
- </Button>
716
- )}
717
-
718
- {selectable && selectedRowIds.size > 0 && (
719
- <div className="text-sm text-muted-foreground">
720
- {t('table.selected', { count: selectedRowIds.size })}
721
- </div>
722
- )}
723
- </div>
724
- </div>
725
- )}
726
-
727
- {/* Table - horizontal scroll indicator via inset shadow on mobile */}
728
- <div className="rounded-md border flex-1 min-h-0 overflow-auto relative bg-background [-webkit-overflow-scrolling:touch] shadow-[inset_-8px_0_8px_-8px_rgba(0,0,0,0.08)]">
729
- <Table>
730
- {caption && <TableCaption>{caption}</TableCaption>}
731
- <TableHeader className="sticky top-0 bg-muted/30 z-10">
732
- <TableRow>
733
- {selectable && (
734
- <TableHead className={cn("w-10 bg-muted/30", frozenColumns > 0 && "sticky left-0 z-20")}>
735
- <Checkbox
736
- checked={allPageRowsSelected ? true : somePageRowsSelected ? 'indeterminate' : false}
737
- onCheckedChange={handleSelectAll}
738
- />
739
- </TableHead>
740
- )}
741
- {showRowNumbers && (
742
- <TableHead className={cn("w-10 bg-muted/30 text-center", frozenColumns > 0 && "sticky z-20")} style={frozenColumns > 0 ? { left: selectable ? 40 : 0 } : undefined}>
743
- <span className="text-xs text-muted-foreground">#</span>
744
- </TableHead>
745
- )}
746
- {columns.map((col, index) => {
747
- const columnWidth = columnWidths[col.accessorKey] || col.width || autoSizedWidths[col.accessorKey];
748
- const isDragging = draggedColumn === index;
749
- const isDragOver = dragOverColumn === index;
750
- const isFrozen = frozenColumns > 0 && index < frozenColumns;
751
- const frozenOffset = isFrozen
752
- ? columns.slice(0, index).reduce((sum, c, i) => {
753
- if (i < frozenColumns) {
754
- const w = columnWidths[c.accessorKey] || c.width || autoSizedWidths[c.accessorKey];
755
- return sum + (typeof w === 'number' ? w : w ? parseInt(String(w), 10) || 150 : 150);
756
- }
757
- return sum;
758
- }, (selectable ? 40 : 0) + (showRowNumbers ? 40 : 0))
759
- : undefined;
760
-
761
- return (
762
- <TableHead
763
- key={col.accessorKey}
764
- className={cn(
765
- col.className,
766
- sortable && col.sortable !== false && 'cursor-pointer select-none',
767
- isDragging && 'opacity-50',
768
- isDragOver && 'border-l-2 border-primary',
769
- col.align === 'right' && 'text-right',
770
- col.align === 'center' && 'text-center',
771
- 'relative group bg-muted/30',
772
- isFrozen && 'sticky z-20',
773
- isFrozen && index === frozenColumns - 1 && 'border-r-2 border-border shadow-[2px_0_4px_-2px_rgba(0,0,0,0.1)]',
774
- )}
775
- style={{
776
- width: columnWidth,
777
- minWidth: columnWidth,
778
- ...(isFrozen && { left: frozenOffset }),
779
- }}
780
- draggable={reorderableColumns}
781
- onDragStart={(e) => handleColumnDragStart(e, index)}
782
- onDragOver={(e) => handleColumnDragOver(e, index)}
783
- onDrop={(e) => handleColumnDrop(e, index)}
784
- onDragEnd={handleColumnDragEnd}
785
- onClick={() => sortable && col.sortable !== false && handleSort(col.accessorKey)}
786
- onContextMenu={(e) => handleColumnContextMenu(e, col.accessorKey)}
787
- >
788
- <div className={cn(
789
- "flex items-center",
790
- col.align === 'right' ? 'justify-end' : 'justify-between'
791
- )}>
792
- <div className="flex items-center gap-1">
793
- {reorderableColumns && (
794
- <GripVertical className="h-4 w-4 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing flex-shrink-0" />
795
- )}
796
- {col.headerIcon && (
797
- <span className="text-muted-foreground flex-shrink-0">{col.headerIcon}</span>
798
- )}
799
- <span className="text-xs font-normal text-muted-foreground whitespace-nowrap truncate">{col.header}</span>
800
- {sortable && col.sortable !== false && getSortIcon(col.accessorKey)}
801
- </div>
802
- {resizableColumns && col.resizable !== false && (
803
- <div
804
- className="absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-primary opacity-0 hover:opacity-100 transition-opacity"
805
- onMouseDown={(e) => handleResizeStart(e, col.accessorKey)}
806
- onClick={(e) => e.stopPropagation()}
807
- />
808
- )}
809
- </div>
810
- </TableHead>
811
- );
812
- })}
813
- {rowActions && (
814
- <TableHead className="w-24 text-right bg-muted/30">{t('common.actions')}</TableHead>
815
- )}
816
- </TableRow>
817
- </TableHeader>
818
- <TableBody>
819
- {paginatedData.length === 0 ? (
820
- <>
821
- <TableRow>
822
- <TableCell
823
- colSpan={columns.length + (selectable ? 1 : 0) + (showRowNumbers ? 1 : 0) + (rowActions ? 1 : 0)}
824
- className="h-24 text-center text-muted-foreground"
825
- >
826
- <div className="flex flex-col items-center justify-center gap-2">
827
- <Search className="h-8 w-8 text-muted-foreground/50" />
828
- <p>{t('table.noResults')}</p>
829
- <p className="text-xs text-muted-foreground/50">{t('table.noResultsHint')}</p>
830
- </div>
831
- </TableCell>
832
- </TableRow>
833
- {/* Ghost placeholder rows – visual skeleton to maintain table height when empty */}
834
- {Array.from({ length: GHOST_ROW_COUNT }).map((_, i) => (
835
- <TableRow key={`ghost-${i}`} className="hover:bg-transparent opacity-[0.15] pointer-events-none" data-testid="ghost-row">
836
- {selectable && <TableCell className="p-3"><div className="h-4 w-4 rounded border border-muted-foreground/30" /></TableCell>}
837
- {showRowNumbers && <TableCell className="text-center p-3"><div className="h-3 w-6 mx-auto rounded bg-muted-foreground/30" /></TableCell>}
838
- {columns.map((_col, ci) => (
839
- <TableCell key={ci} className="p-3">
840
- <div className={cn("h-3 rounded bg-muted-foreground/30", ghostCellWidth(ci, columns.length))} />
841
- </TableCell>
842
- ))}
843
- {rowActions && <TableCell className="p-3"><div className="h-3 w-8 rounded bg-muted-foreground/30" /></TableCell>}
844
- </TableRow>
845
- ))}
846
- </>
847
- ) : (
848
- <>
849
- {paginatedData.map((row, rowIndex) => {
850
- const globalIndex = (currentPage - 1) * pageSize + rowIndex;
851
- const rowId = getRowId(row, globalIndex);
852
- const isSelected = selectedRowIds.has(rowId);
853
- const rowHasChanges = pendingChanges.has(rowIndex);
854
- const rowChanges = pendingChanges.get(rowIndex) || {};
855
-
856
- return (
857
- <TableRow
858
- key={rowId}
859
- data-state={isSelected ? 'selected' : undefined}
860
- className={cn(
861
- "bg-background border-b border-border hover:bg-muted/50 group/row",
862
- schema.onRowClick && "cursor-pointer",
863
- rowHasChanges && "bg-amber-50 dark:bg-amber-950/20",
864
- rowClassName && rowClassName(row, rowIndex)
865
- )}
866
- style={rowStyle ? rowStyle(row, rowIndex) : undefined}
867
- onClick={(e) => {
868
- if (schema.onRowClick && !e.defaultPrevented) {
869
- // Simple heuristic to avoid triggering on interactive elements if they didn't stop propagation
870
- const target = e.target as HTMLElement;
871
- if (target.closest('button') || target.closest('[role="checkbox"]') || target.closest('a')) {
872
- return;
873
- }
874
- schema.onRowClick(row);
875
- }
876
- }}
877
- >
878
- {selectable && (
879
- <TableCell className={cn(frozenColumns > 0 && "sticky left-0 z-10 bg-background", selectionStyle === 'hover' && "relative")}>
880
- {selectionStyle === 'hover' ? (
881
- <div className={cn("transition-opacity", isSelected ? "opacity-100" : "opacity-0 group-hover/row:opacity-100")}>
882
- <Checkbox
883
- checked={isSelected}
884
- onCheckedChange={(checked) => handleSelectRow(rowId, checked as boolean)}
885
- />
886
- </div>
887
- ) : (
888
- <Checkbox
889
- checked={isSelected}
890
- onCheckedChange={(checked) => handleSelectRow(rowId, checked as boolean)}
891
- />
892
- )}
893
- </TableCell>
894
- )}
895
- {showRowNumbers && (
896
- <TableCell className={cn("text-center w-10 relative", frozenColumns > 0 && "sticky z-10 bg-background")} style={frozenColumns > 0 ? { left: selectable ? 40 : 0 } : undefined}>
897
- <span className={cn("text-xs text-muted-foreground tabular-nums select-none", selectable ? "group-hover/row:hidden" : "group-hover/row:invisible")}>
898
- {globalIndex + 1}
899
- </span>
900
- {selectable ? (
901
- <div className="absolute inset-0 hidden group-hover/row:flex items-center justify-center">
902
- <Checkbox
903
- checked={isSelected}
904
- onCheckedChange={(checked) => handleSelectRow(rowId, checked as boolean)}
905
- data-testid="row-hover-checkbox"
906
- />
907
- </div>
908
- ) : schema.onRowClick && (
909
- <button
910
- type="button"
911
- className="absolute inset-0 hidden group-hover/row:flex items-center justify-center gap-0.5 text-xs font-medium text-primary hover:text-primary/80"
912
- data-testid="row-expand-button"
913
- onClick={(e) => {
914
- e.stopPropagation();
915
- schema.onRowClick?.(row);
916
- }}
917
- title="Open record"
918
- >
919
- <span>{t('table.open')}</span>
920
- <ChevronRight className="h-3 w-3" />
921
- </button>
922
- )}
923
- </TableCell>
924
- )}
925
- {columns.map((col, colIndex) => {
926
- const columnWidth = columnWidths[col.accessorKey] || col.width || autoSizedWidths[col.accessorKey];
927
- const originalValue = row[col.accessorKey];
928
- const hasPendingChange = rowChanges[col.accessorKey] !== undefined;
929
- const cellValue = hasPendingChange ? rowChanges[col.accessorKey] : originalValue;
930
- const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.columnKey === col.accessorKey;
931
- const isEditable = editable && col.editable !== false;
932
- const isFrozen = frozenColumns > 0 && colIndex < frozenColumns;
933
- const frozenOffset = isFrozen
934
- ? columns.slice(0, colIndex).reduce((sum, c, i) => {
935
- if (i < frozenColumns) {
936
- const w = columnWidths[c.accessorKey] || c.width || autoSizedWidths[c.accessorKey];
937
- return sum + (typeof w === 'number' ? w : w ? parseInt(String(w), 10) || 150 : 150);
938
- }
939
- return sum;
940
- }, (selectable ? 40 : 0) + (showRowNumbers ? 40 : 0))
941
- : undefined;
942
-
943
- return (
944
- <TableCell
945
- key={colIndex}
946
- className={cn(
947
- col.cellClassName,
948
- col.align === 'right' && 'text-right',
949
- col.align === 'center' && 'text-center',
950
- isEditable && !isEditing && "cursor-text hover:bg-muted/50",
951
- hasPendingChange && "font-semibold text-amber-700 dark:text-amber-400",
952
- isFrozen && 'sticky z-10 bg-background',
953
- isFrozen && colIndex === frozenColumns - 1 && 'border-r-2 border-border shadow-[2px_0_4px_-2px_rgba(0,0,0,0.1)]',
954
- )}
955
- style={{
956
- width: columnWidth,
957
- minWidth: columnWidth,
958
- maxWidth: columnWidth,
959
- ...(isFrozen && { left: frozenOffset }),
960
- }}
961
- onDoubleClick={() => isEditable && !singleClickEdit && startEdit(rowIndex, col.accessorKey)}
962
- onClick={() => isEditable && singleClickEdit && startEdit(rowIndex, col.accessorKey)}
963
- onKeyDown={(e) => handleCellKeyDown(e, rowIndex, col.accessorKey)}
964
- tabIndex={0}
965
- >
966
- {isEditing ? (
967
- <Input
968
- ref={editInputRef}
969
- value={editValue}
970
- onChange={(e) => setEditValue(e.target.value)}
971
- onKeyDown={handleEditKeyDown}
972
- className="h-8 px-2 py-1"
973
- />
974
- ) : typeof col.cell === 'function' ? (
975
- col.cell(cellValue, row)
976
- ) : (
977
- cellValue != null && typeof cellValue === 'object' ? String(cellValue) : cellValue
978
- )}
979
- </TableCell>
980
- );
981
- })}
982
- {rowActions && (
983
- <TableCell className="text-right">
984
- <div className="flex items-center justify-end gap-1">
985
- {rowHasChanges && (schema.onRowSave || schema.onBatchSave) ? (
986
- <>
987
- <Button
988
- variant="ghost"
989
- size="icon"
990
- onClick={() => cancelRowChanges(rowIndex)}
991
- disabled={isSaving}
992
- title="Cancel changes"
993
- >
994
- <X className="h-4 w-4" />
995
- </Button>
996
- <Button
997
- variant="ghost"
998
- size="icon"
999
- onClick={() => saveRow(rowIndex)}
1000
- disabled={isSaving}
1001
- title="Save row"
1002
- >
1003
- <Save className="h-4 w-4 text-green-600" />
1004
- </Button>
1005
- </>
1006
- ) : (
1007
- <>
1008
- <Button
1009
- variant="ghost"
1010
- size="icon"
1011
- onClick={() => schema.onRowEdit?.(row)}
1012
- >
1013
- <Edit className="h-4 w-4" />
1014
- </Button>
1015
- <Button
1016
- variant="ghost"
1017
- size="icon"
1018
- onClick={() => schema.onRowDelete?.(row)}
1019
- >
1020
- <Trash2 className="h-4 w-4 text-destructive" />
1021
- </Button>
1022
- </>
1023
- )}
1024
- </div>
1025
- </TableCell>
1026
- )}
1027
- </TableRow>
1028
- );
1029
- })}
1030
- {/* Add record row (Airtable-style) */}
1031
- {showAddRow && (
1032
- <TableRow
1033
- className="hover:bg-muted/30 cursor-pointer border-b border-border"
1034
- data-testid="add-record-row"
1035
- onClick={() => schema.onAddRecord?.()}
1036
- >
1037
- <TableCell
1038
- colSpan={columns.length + (selectable ? 1 : 0) + (showRowNumbers ? 1 : 0) + (rowActions ? 1 : 0)}
1039
- className="h-9 px-3 py-1.5"
1040
- >
1041
- <span className="flex items-center gap-1.5 text-muted-foreground text-sm hover:text-foreground transition-colors">
1042
- <Plus className="h-3.5 w-3.5" />
1043
- {t('table.addRecord')}
1044
- </span>
1045
- </TableCell>
1046
- </TableRow>
1047
- )}
1048
- {/* Filler rows to maintain height consistency (only when pagination is enabled) */}
1049
- {pagination && paginatedData.length > 0 && Array.from({ length: Math.max(0, pageSize - paginatedData.length) }).map((_, i) => (
1050
- <TableRow key={`empty-${i}`} className="hover:bg-transparent">
1051
- <TableCell colSpan={columns.length + (selectable ? 1 : 0) + (showRowNumbers ? 1 : 0) + (rowActions ? 1 : 0)} className="h-[52px] p-0" />
1052
- </TableRow>
1053
- ))}
1054
- </>
1055
- )}
1056
- </TableBody>
1057
- </Table>
1058
- </div>
1059
-
1060
- {/* Pagination */}
1061
- {pagination && sortedData.length > 0 && (
1062
- <div className="flex flex-col sm:flex-row items-center justify-between gap-2">
1063
- <div className="flex items-center gap-2">
1064
- <span className="text-xs sm:text-sm text-muted-foreground">{t('table.rowsPerPage')}:</span>
1065
- <Select
1066
- value={pageSize.toString()}
1067
- onValueChange={(value) => {
1068
- setPageSize(Number(value));
1069
- setCurrentPage(1);
1070
- }}
1071
- >
1072
- <SelectTrigger className="w-20">
1073
- <SelectValue />
1074
- </SelectTrigger>
1075
- <SelectContent>
1076
- <SelectItem value="5">5</SelectItem>
1077
- <SelectItem value="10">10</SelectItem>
1078
- <SelectItem value="20">20</SelectItem>
1079
- <SelectItem value="50">50</SelectItem>
1080
- <SelectItem value="100">100</SelectItem>
1081
- </SelectContent>
1082
- </Select>
1083
- </div>
1084
-
1085
- <div className="flex items-center gap-2">
1086
- <span className="text-xs sm:text-sm text-muted-foreground">
1087
- {t('table.pageInfo', { current: currentPage, total: totalPages })} <span className="hidden sm:inline">({t('table.totalRecords', { count: sortedData.length })})</span>
1088
- </span>
1089
- <div className="flex items-center gap-1">
1090
- <Button
1091
- variant="outline"
1092
- size="icon"
1093
- onClick={() => setCurrentPage(1)}
1094
- disabled={currentPage === 1}
1095
- >
1096
- <ChevronsLeft className="h-4 w-4" />
1097
- </Button>
1098
- <Button
1099
- variant="outline"
1100
- size="icon"
1101
- onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
1102
- disabled={currentPage === 1}
1103
- >
1104
- <ChevronLeft className="h-4 w-4" />
1105
- </Button>
1106
- <Button
1107
- variant="outline"
1108
- size="icon"
1109
- onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
1110
- disabled={currentPage === totalPages}
1111
- >
1112
- <ChevronRight className="h-4 w-4" />
1113
- </Button>
1114
- <Button
1115
- variant="outline"
1116
- size="icon"
1117
- onClick={() => setCurrentPage(totalPages)}
1118
- disabled={currentPage === totalPages}
1119
- >
1120
- <ChevronsRight className="h-4 w-4" />
1121
- </Button>
1122
- </div>
1123
- </div>
1124
- </div>
1125
- )}
1126
-
1127
- {/* Column header context menu */}
1128
- {contextMenu && (
1129
- <div
1130
- className="fixed z-50 min-w-[160px] rounded-md border bg-popover p-1 shadow-md"
1131
- style={{ left: contextMenu.x, top: contextMenu.y }}
1132
- data-testid="column-context-menu"
1133
- onClick={(e) => e.stopPropagation()}
1134
- >
1135
- {sortable && (
1136
- <>
1137
- <button
1138
- type="button"
1139
- className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground cursor-pointer"
1140
- onClick={() => {
1141
- setSortColumn(contextMenu.columnKey);
1142
- setSortDirection('asc');
1143
- setContextMenu(null);
1144
- }}
1145
- >
1146
- <ChevronUp className="h-3.5 w-3.5" />
1147
- {t('table.sortAsc')}
1148
- </button>
1149
- <button
1150
- type="button"
1151
- className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground cursor-pointer"
1152
- onClick={() => {
1153
- setSortColumn(contextMenu.columnKey);
1154
- setSortDirection('desc');
1155
- setContextMenu(null);
1156
- }}
1157
- >
1158
- <ChevronDown className="h-3.5 w-3.5" />
1159
- {t('table.sortDesc')}
1160
- </button>
1161
- <div className="my-1 h-px bg-border" />
1162
- </>
1163
- )}
1164
- <button
1165
- type="button"
1166
- className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground cursor-pointer"
1167
- onClick={() => hideColumn(contextMenu.columnKey)}
1168
- >
1169
- <X className="h-3.5 w-3.5" />
1170
- {t('table.hideColumn')}
1171
- </button>
1172
- </div>
1173
- )}
1174
- </div>
1175
- );
1176
- };
1177
-
1178
- // Register the component
1179
- ComponentRegistry.register('data-table', DataTableRenderer, {
1180
- namespace: 'ui',
1181
- label: 'Data Table',
1182
- icon: 'table',
1183
- inputs: [
1184
- { name: 'caption', type: 'string', label: 'Caption' },
1185
- {
1186
- name: 'columns',
1187
- type: 'array',
1188
- label: 'Columns',
1189
- description: 'Array of { header, accessorKey, className, width, sortable, filterable, resizable }',
1190
- required: true,
1191
- },
1192
- {
1193
- name: 'data',
1194
- type: 'array',
1195
- label: 'Data',
1196
- description: 'Array of data objects',
1197
- required: true,
1198
- },
1199
- { name: 'pagination', type: 'boolean', label: 'Enable Pagination', defaultValue: true },
1200
- { name: 'pageSize', type: 'number', label: 'Page Size', defaultValue: 10 },
1201
- { name: 'searchable', type: 'boolean', label: 'Enable Search', defaultValue: true },
1202
- { name: 'selectable', type: 'boolean', label: 'Enable Row Selection', defaultValue: false },
1203
- { name: 'sortable', type: 'boolean', label: 'Enable Sorting', defaultValue: true },
1204
- { name: 'exportable', type: 'boolean', label: 'Enable Export', defaultValue: false },
1205
- { name: 'rowActions', type: 'boolean', label: 'Show Row Actions', defaultValue: false },
1206
- { name: 'resizableColumns', type: 'boolean', label: 'Enable Column Resizing', defaultValue: true },
1207
- { name: 'reorderableColumns', type: 'boolean', label: 'Enable Column Reordering', defaultValue: true },
1208
- { name: 'className', type: 'string', label: 'CSS Class' },
1209
- ],
1210
- defaultProps: {
1211
- caption: 'Enterprise Data Table',
1212
- pagination: true,
1213
- pageSize: 10,
1214
- searchable: true,
1215
- selectable: true,
1216
- sortable: true,
1217
- exportable: true,
1218
- rowActions: true,
1219
- resizableColumns: true,
1220
- reorderableColumns: true,
1221
- columns: [
1222
- { header: 'ID', accessorKey: 'id', width: '80px' },
1223
- { header: 'Name', accessorKey: 'name' },
1224
- { header: 'Email', accessorKey: 'email' },
1225
- { header: 'Status', accessorKey: 'status' },
1226
- { header: 'Role', accessorKey: 'role' },
1227
- ],
1228
- data: [
1229
- { id: 1, name: 'John Doe', email: 'john@example.com', status: 'Active', role: 'Admin' },
1230
- { id: 2, name: 'Jane Smith', email: 'jane@example.com', status: 'Active', role: 'User' },
1231
- { id: 3, name: 'Bob Johnson', email: 'bob@example.com', status: 'Inactive', role: 'User' },
1232
- { id: 4, name: 'Alice Williams', email: 'alice@example.com', status: 'Active', role: 'Manager' },
1233
- { id: 5, name: 'Charlie Brown', email: 'charlie@example.com', status: 'Active', role: 'User' },
1234
- { id: 6, name: 'Diana Prince', email: 'diana@example.com', status: 'Active', role: 'Admin' },
1235
- { id: 7, name: 'Ethan Hunt', email: 'ethan@example.com', status: 'Inactive', role: 'User' },
1236
- { id: 8, name: 'Fiona Gallagher', email: 'fiona@example.com', status: 'Active', role: 'User' },
1237
- { id: 9, name: 'George Wilson', email: 'george@example.com', status: 'Active', role: 'Manager' },
1238
- { id: 10, name: 'Hannah Montana', email: 'hannah@example.com', status: 'Active', role: 'User' },
1239
- { id: 11, name: 'Ivan Drago', email: 'ivan@example.com', status: 'Inactive', role: 'User' },
1240
- { id: 12, name: 'Julia Roberts', email: 'julia@example.com', status: 'Active', role: 'Admin' },
1241
- ],
1242
- },
1243
- });