@freighttech/ui 0.13.18

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 (1477) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +31 -0
  3. package/dist/backend/AppShell.js +1289 -0
  4. package/dist/backend/AppShell.js.map +7 -0
  5. package/dist/backend/BackendChromeProvider.js +83 -0
  6. package/dist/backend/BackendChromeProvider.js.map +7 -0
  7. package/dist/backend/BackendPageSurface.js +19 -0
  8. package/dist/backend/BackendPageSurface.js.map +7 -0
  9. package/dist/backend/CollapsibleNavSection.js +149 -0
  10. package/dist/backend/CollapsibleNavSection.js.map +7 -0
  11. package/dist/backend/ContextHelp.js +33 -0
  12. package/dist/backend/ContextHelp.js.map +7 -0
  13. package/dist/backend/CrudForm.js +3163 -0
  14. package/dist/backend/CrudForm.js.map +7 -0
  15. package/dist/backend/DataTable.js +2091 -0
  16. package/dist/backend/DataTable.js.map +7 -0
  17. package/dist/backend/EmptyState.js +52 -0
  18. package/dist/backend/EmptyState.js.map +7 -0
  19. package/dist/backend/FilterBar.js +147 -0
  20. package/dist/backend/FilterBar.js.map +7 -0
  21. package/dist/backend/FilterOverlay.js +337 -0
  22. package/dist/backend/FilterOverlay.js.map +7 -0
  23. package/dist/backend/FlashMessages.js +158 -0
  24. package/dist/backend/FlashMessages.js.map +7 -0
  25. package/dist/backend/HtmlRichTextEditor.js +129 -0
  26. package/dist/backend/HtmlRichTextEditor.js.map +7 -0
  27. package/dist/backend/IntegrationsButton.js +25 -0
  28. package/dist/backend/IntegrationsButton.js.map +7 -0
  29. package/dist/backend/JsonBuilder.js +323 -0
  30. package/dist/backend/JsonBuilder.js.map +7 -0
  31. package/dist/backend/JsonDisplay.js +207 -0
  32. package/dist/backend/JsonDisplay.js.map +7 -0
  33. package/dist/backend/NextStepCallout.js +79 -0
  34. package/dist/backend/NextStepCallout.js.map +7 -0
  35. package/dist/backend/Page.js +27 -0
  36. package/dist/backend/Page.js.map +7 -0
  37. package/dist/backend/PerspectiveSidebar.js +456 -0
  38. package/dist/backend/PerspectiveSidebar.js.map +7 -0
  39. package/dist/backend/ProfileDropdown.js +341 -0
  40. package/dist/backend/ProfileDropdown.js.map +7 -0
  41. package/dist/backend/RowActions.js +155 -0
  42. package/dist/backend/RowActions.js.map +7 -0
  43. package/dist/backend/SectionHeader.js +74 -0
  44. package/dist/backend/SectionHeader.js.map +7 -0
  45. package/dist/backend/SettingsButton.js +25 -0
  46. package/dist/backend/SettingsButton.js.map +7 -0
  47. package/dist/backend/Sidebar.js +510 -0
  48. package/dist/backend/Sidebar.js.map +7 -0
  49. package/dist/backend/Sidebar.stories.js +166 -0
  50. package/dist/backend/Sidebar.stories.js.map +7 -0
  51. package/dist/backend/TruncatedCell.js +92 -0
  52. package/dist/backend/TruncatedCell.js.map +7 -0
  53. package/dist/backend/UserMenu.js +146 -0
  54. package/dist/backend/UserMenu.js.map +7 -0
  55. package/dist/backend/ValueIcons.js +34 -0
  56. package/dist/backend/ValueIcons.js.map +7 -0
  57. package/dist/backend/WebhookSetupGuide.js +79 -0
  58. package/dist/backend/WebhookSetupGuide.js.map +7 -0
  59. package/dist/backend/charts/BarChart.js +154 -0
  60. package/dist/backend/charts/BarChart.js.map +7 -0
  61. package/dist/backend/charts/ChartUtils.js +141 -0
  62. package/dist/backend/charts/ChartUtils.js.map +7 -0
  63. package/dist/backend/charts/ComboChart.js +261 -0
  64. package/dist/backend/charts/ComboChart.js.map +7 -0
  65. package/dist/backend/charts/ComboChart.stories.js +88 -0
  66. package/dist/backend/charts/ComboChart.stories.js.map +7 -0
  67. package/dist/backend/charts/KpiCard.js +96 -0
  68. package/dist/backend/charts/KpiCard.js.map +7 -0
  69. package/dist/backend/charts/LineChart.js +165 -0
  70. package/dist/backend/charts/LineChart.js.map +7 -0
  71. package/dist/backend/charts/PieChart.js +121 -0
  72. package/dist/backend/charts/PieChart.js.map +7 -0
  73. package/dist/backend/charts/ScatterChart.js +320 -0
  74. package/dist/backend/charts/ScatterChart.js.map +7 -0
  75. package/dist/backend/charts/TopNTable.js +91 -0
  76. package/dist/backend/charts/TopNTable.js.map +7 -0
  77. package/dist/backend/charts/WaterfallChart.js +136 -0
  78. package/dist/backend/charts/WaterfallChart.js.map +7 -0
  79. package/dist/backend/charts/index.js +30 -0
  80. package/dist/backend/charts/index.js.map +7 -0
  81. package/dist/backend/columns/ColumnChooserPanel.js +244 -0
  82. package/dist/backend/columns/ColumnChooserPanel.js.map +7 -0
  83. package/dist/backend/confirm-dialog/ConfirmDialog.js +214 -0
  84. package/dist/backend/confirm-dialog/ConfirmDialog.js.map +7 -0
  85. package/dist/backend/confirm-dialog/index.js +7 -0
  86. package/dist/backend/confirm-dialog/index.js.map +7 -0
  87. package/dist/backend/confirm-dialog/useConfirmDialog.js +113 -0
  88. package/dist/backend/confirm-dialog/useConfirmDialog.js.map +7 -0
  89. package/dist/backend/custom-fields/FieldDefinitionsEditor.js +1352 -0
  90. package/dist/backend/custom-fields/FieldDefinitionsEditor.js.map +7 -0
  91. package/dist/backend/custom-fields/FieldDefinitionsManager.js +332 -0
  92. package/dist/backend/custom-fields/FieldDefinitionsManager.js.map +7 -0
  93. package/dist/backend/dashboard/DashboardScreen.js +598 -0
  94. package/dist/backend/dashboard/DashboardScreen.js.map +7 -0
  95. package/dist/backend/dashboard/index.js +5 -0
  96. package/dist/backend/dashboard/index.js.map +7 -0
  97. package/dist/backend/dashboard/widgetRegistry.js +55 -0
  98. package/dist/backend/dashboard/widgetRegistry.js.map +7 -0
  99. package/dist/backend/date-range/DateRangeSelect.js +39 -0
  100. package/dist/backend/date-range/DateRangeSelect.js.map +7 -0
  101. package/dist/backend/date-range/InlineDateRangeSelect.js +42 -0
  102. package/dist/backend/date-range/InlineDateRangeSelect.js.map +7 -0
  103. package/dist/backend/date-range/InlineGranularitySelect.js +40 -0
  104. package/dist/backend/date-range/InlineGranularitySelect.js.map +7 -0
  105. package/dist/backend/date-range/dateRanges.js +176 -0
  106. package/dist/backend/date-range/dateRanges.js.map +7 -0
  107. package/dist/backend/date-range/index.js +25 -0
  108. package/dist/backend/date-range/index.js.map +7 -0
  109. package/dist/backend/detail/AccessDeniedMessage.js +28 -0
  110. package/dist/backend/detail/AccessDeniedMessage.js.map +7 -0
  111. package/dist/backend/detail/ActivitiesSection.js +947 -0
  112. package/dist/backend/detail/ActivitiesSection.js.map +7 -0
  113. package/dist/backend/detail/AddressEditor.js +417 -0
  114. package/dist/backend/detail/AddressEditor.js.map +7 -0
  115. package/dist/backend/detail/AddressTiles.js +441 -0
  116. package/dist/backend/detail/AddressTiles.js.map +7 -0
  117. package/dist/backend/detail/AddressesSection.js +274 -0
  118. package/dist/backend/detail/AddressesSection.js.map +7 -0
  119. package/dist/backend/detail/AttachmentDeleteDialog.js +41 -0
  120. package/dist/backend/detail/AttachmentDeleteDialog.js.map +7 -0
  121. package/dist/backend/detail/AttachmentMetadataDialog.js +519 -0
  122. package/dist/backend/detail/AttachmentMetadataDialog.js.map +7 -0
  123. package/dist/backend/detail/AttachmentVisualPreview.js +132 -0
  124. package/dist/backend/detail/AttachmentVisualPreview.js.map +7 -0
  125. package/dist/backend/detail/AttachmentsSection.js +281 -0
  126. package/dist/backend/detail/AttachmentsSection.js.map +7 -0
  127. package/dist/backend/detail/CustomDataSection.js +575 -0
  128. package/dist/backend/detail/CustomDataSection.js.map +7 -0
  129. package/dist/backend/detail/DetailFieldsSection.js +85 -0
  130. package/dist/backend/detail/DetailFieldsSection.js.map +7 -0
  131. package/dist/backend/detail/ErrorMessage.js +28 -0
  132. package/dist/backend/detail/ErrorMessage.js.map +7 -0
  133. package/dist/backend/detail/InlineEditors.js +697 -0
  134. package/dist/backend/detail/InlineEditors.js.map +7 -0
  135. package/dist/backend/detail/LoadingMessage.js +14 -0
  136. package/dist/backend/detail/LoadingMessage.js.map +7 -0
  137. package/dist/backend/detail/NotesSection.js +1022 -0
  138. package/dist/backend/detail/NotesSection.js.map +7 -0
  139. package/dist/backend/detail/TabEmptyState.js +25 -0
  140. package/dist/backend/detail/TabEmptyState.js.map +7 -0
  141. package/dist/backend/detail/TagsSection.js +375 -0
  142. package/dist/backend/detail/TagsSection.js.map +7 -0
  143. package/dist/backend/detail/addressFormat.js +77 -0
  144. package/dist/backend/detail/addressFormat.js.map +7 -0
  145. package/dist/backend/detail/index.js +36 -0
  146. package/dist/backend/detail/index.js.map +7 -0
  147. package/dist/backend/devtools/UmesDevToolsPanel.js +109 -0
  148. package/dist/backend/devtools/UmesDevToolsPanel.js.map +7 -0
  149. package/dist/backend/devtools/components/ConflictWarnings.js +32 -0
  150. package/dist/backend/devtools/components/ConflictWarnings.js.map +7 -0
  151. package/dist/backend/devtools/components/EnricherTiming.js +46 -0
  152. package/dist/backend/devtools/components/EnricherTiming.js.map +7 -0
  153. package/dist/backend/devtools/components/EventFlow.js +34 -0
  154. package/dist/backend/devtools/components/EventFlow.js.map +7 -0
  155. package/dist/backend/devtools/components/ExtensionPointList.js +58 -0
  156. package/dist/backend/devtools/components/ExtensionPointList.js.map +7 -0
  157. package/dist/backend/devtools/components/InterceptorActivity.js +36 -0
  158. package/dist/backend/devtools/components/InterceptorActivity.js.map +7 -0
  159. package/dist/backend/devtools/index.js +7 -0
  160. package/dist/backend/devtools/index.js.map +7 -0
  161. package/dist/backend/devtools/useUmesDevTools.js +125 -0
  162. package/dist/backend/devtools/useUmesDevTools.js.map +7 -0
  163. package/dist/backend/document-viewer/RegionOverlay.js +92 -0
  164. package/dist/backend/document-viewer/RegionOverlay.js.map +7 -0
  165. package/dist/backend/document-viewer/index.js +6 -0
  166. package/dist/backend/document-viewer/index.js.map +7 -0
  167. package/dist/backend/dynamic-grid/DynamicGrid.js +130 -0
  168. package/dist/backend/dynamic-grid/DynamicGrid.js.map +7 -0
  169. package/dist/backend/dynamic-grid/adapters.js +34 -0
  170. package/dist/backend/dynamic-grid/adapters.js.map +7 -0
  171. package/dist/backend/dynamic-grid/components/GridCell.js +149 -0
  172. package/dist/backend/dynamic-grid/components/GridCell.js.map +7 -0
  173. package/dist/backend/dynamic-grid/components/GridRow.js +32 -0
  174. package/dist/backend/dynamic-grid/components/GridRow.js.map +7 -0
  175. package/dist/backend/dynamic-grid/hooks/useGridKeyboardNav.js +115 -0
  176. package/dist/backend/dynamic-grid/hooks/useGridKeyboardNav.js.map +7 -0
  177. package/dist/backend/dynamic-grid/index.js +9 -0
  178. package/dist/backend/dynamic-grid/index.js.map +7 -0
  179. package/dist/backend/dynamic-grid/types.js +1 -0
  180. package/dist/backend/dynamic-grid/types.js.map +7 -0
  181. package/dist/backend/dynamic-table/DynamicTable.js +2607 -0
  182. package/dist/backend/dynamic-table/DynamicTable.js.map +7 -0
  183. package/dist/backend/dynamic-table/DynamicTable.stories.js +233 -0
  184. package/dist/backend/dynamic-table/DynamicTable.stories.js.map +7 -0
  185. package/dist/backend/dynamic-table/components/ActiveFilterChips.js +60 -0
  186. package/dist/backend/dynamic-table/components/ActiveFilterChips.js.map +7 -0
  187. package/dist/backend/dynamic-table/components/BulkActionsBar.js +58 -0
  188. package/dist/backend/dynamic-table/components/BulkActionsBar.js.map +7 -0
  189. package/dist/backend/dynamic-table/components/Cell.js +155 -0
  190. package/dist/backend/dynamic-table/components/Cell.js.map +7 -0
  191. package/dist/backend/dynamic-table/components/CellCommentDialog.js +726 -0
  192. package/dist/backend/dynamic-table/components/CellCommentDialog.js.map +7 -0
  193. package/dist/backend/dynamic-table/components/CellCommentHoverPopup.js +77 -0
  194. package/dist/backend/dynamic-table/components/CellCommentHoverPopup.js.map +7 -0
  195. package/dist/backend/dynamic-table/components/ColumnFilterPopover.js +485 -0
  196. package/dist/backend/dynamic-table/components/ColumnFilterPopover.js.map +7 -0
  197. package/dist/backend/dynamic-table/components/ColumnHeaderMenu.js +173 -0
  198. package/dist/backend/dynamic-table/components/ColumnHeaderMenu.js.map +7 -0
  199. package/dist/backend/dynamic-table/components/ColumnHeaders.js +528 -0
  200. package/dist/backend/dynamic-table/components/ColumnHeaders.js.map +7 -0
  201. package/dist/backend/dynamic-table/components/CompactLabelCell.js +64 -0
  202. package/dist/backend/dynamic-table/components/CompactLabelCell.js.map +7 -0
  203. package/dist/backend/dynamic-table/components/ConfigureViewFields.js +139 -0
  204. package/dist/backend/dynamic-table/components/ConfigureViewFields.js.map +7 -0
  205. package/dist/backend/dynamic-table/components/ConfigureViewFilters.js +305 -0
  206. package/dist/backend/dynamic-table/components/ConfigureViewFilters.js.map +7 -0
  207. package/dist/backend/dynamic-table/components/ConfigureViewFormatting.js +169 -0
  208. package/dist/backend/dynamic-table/components/ConfigureViewFormatting.js.map +7 -0
  209. package/dist/backend/dynamic-table/components/ConfigureViewFormulas.js +298 -0
  210. package/dist/backend/dynamic-table/components/ConfigureViewFormulas.js.map +7 -0
  211. package/dist/backend/dynamic-table/components/ConfigureViewGrouping.js +224 -0
  212. package/dist/backend/dynamic-table/components/ConfigureViewGrouping.js.map +7 -0
  213. package/dist/backend/dynamic-table/components/ConfigureViewLinkedColumns.js +167 -0
  214. package/dist/backend/dynamic-table/components/ConfigureViewLinkedColumns.js.map +7 -0
  215. package/dist/backend/dynamic-table/components/ConfigureViewPanel.js +592 -0
  216. package/dist/backend/dynamic-table/components/ConfigureViewPanel.js.map +7 -0
  217. package/dist/backend/dynamic-table/components/ConfigureViewRollups.js +269 -0
  218. package/dist/backend/dynamic-table/components/ConfigureViewRollups.js.map +7 -0
  219. package/dist/backend/dynamic-table/components/ConfigureViewSorting.js +107 -0
  220. package/dist/backend/dynamic-table/components/ConfigureViewSorting.js.map +7 -0
  221. package/dist/backend/dynamic-table/components/ContextMenu.js +91 -0
  222. package/dist/backend/dynamic-table/components/ContextMenu.js.map +7 -0
  223. package/dist/backend/dynamic-table/components/DateCell.js +80 -0
  224. package/dist/backend/dynamic-table/components/DateCell.js.map +7 -0
  225. package/dist/backend/dynamic-table/components/Debugger.js +311 -0
  226. package/dist/backend/dynamic-table/components/Debugger.js.map +7 -0
  227. package/dist/backend/dynamic-table/components/DensityControl.js +289 -0
  228. package/dist/backend/dynamic-table/components/DensityControl.js.map +7 -0
  229. package/dist/backend/dynamic-table/components/EmptyState.js +42 -0
  230. package/dist/backend/dynamic-table/components/EmptyState.js.map +7 -0
  231. package/dist/backend/dynamic-table/components/EntitySearchEditor.js +369 -0
  232. package/dist/backend/dynamic-table/components/EntitySearchEditor.js.map +7 -0
  233. package/dist/backend/dynamic-table/components/ExportMenu.js +63 -0
  234. package/dist/backend/dynamic-table/components/ExportMenu.js.map +7 -0
  235. package/dist/backend/dynamic-table/components/FilterDatePicker.js +165 -0
  236. package/dist/backend/dynamic-table/components/FilterDatePicker.js.map +7 -0
  237. package/dist/backend/dynamic-table/components/FilterValueInput.js +200 -0
  238. package/dist/backend/dynamic-table/components/FilterValueInput.js.map +7 -0
  239. package/dist/backend/dynamic-table/components/FooterTotalsRow.js +165 -0
  240. package/dist/backend/dynamic-table/components/FooterTotalsRow.js.map +7 -0
  241. package/dist/backend/dynamic-table/components/FullscreenOverlay.js +51 -0
  242. package/dist/backend/dynamic-table/components/FullscreenOverlay.js.map +7 -0
  243. package/dist/backend/dynamic-table/components/GroupHeaderRow.js +108 -0
  244. package/dist/backend/dynamic-table/components/GroupHeaderRow.js.map +7 -0
  245. package/dist/backend/dynamic-table/components/GroupSummaryRow.js +134 -0
  246. package/dist/backend/dynamic-table/components/GroupSummaryRow.js.map +7 -0
  247. package/dist/backend/dynamic-table/components/ImportPanel.js +341 -0
  248. package/dist/backend/dynamic-table/components/ImportPanel.js.map +7 -0
  249. package/dist/backend/dynamic-table/components/ImportPreviewGrid.js +114 -0
  250. package/dist/backend/dynamic-table/components/ImportPreviewGrid.js.map +7 -0
  251. package/dist/backend/dynamic-table/components/LoadErrorState.js +23 -0
  252. package/dist/backend/dynamic-table/components/LoadErrorState.js.map +7 -0
  253. package/dist/backend/dynamic-table/components/MentionPopup.js +149 -0
  254. package/dist/backend/dynamic-table/components/MentionPopup.js.map +7 -0
  255. package/dist/backend/dynamic-table/components/PerspectiveDeleteDialog.js +57 -0
  256. package/dist/backend/dynamic-table/components/PerspectiveDeleteDialog.js.map +7 -0
  257. package/dist/backend/dynamic-table/components/PerspectivePublishDialog.js +100 -0
  258. package/dist/backend/dynamic-table/components/PerspectivePublishDialog.js.map +7 -0
  259. package/dist/backend/dynamic-table/components/PerspectiveTabMenu.js +159 -0
  260. package/dist/backend/dynamic-table/components/PerspectiveTabMenu.js.map +7 -0
  261. package/dist/backend/dynamic-table/components/PerspectiveTabs.js +319 -0
  262. package/dist/backend/dynamic-table/components/PerspectiveTabs.js.map +7 -0
  263. package/dist/backend/dynamic-table/components/PerspectiveTemplateMenu.js +90 -0
  264. package/dist/backend/dynamic-table/components/PerspectiveTemplateMenu.js.map +7 -0
  265. package/dist/backend/dynamic-table/components/RowHeaderCell.js +58 -0
  266. package/dist/backend/dynamic-table/components/RowHeaderCell.js.map +7 -0
  267. package/dist/backend/dynamic-table/components/SearchBar.js +221 -0
  268. package/dist/backend/dynamic-table/components/SearchBar.js.map +7 -0
  269. package/dist/backend/dynamic-table/components/SelectMenu.js +186 -0
  270. package/dist/backend/dynamic-table/components/SelectMenu.js.map +7 -0
  271. package/dist/backend/dynamic-table/components/StaleDataBar.js +26 -0
  272. package/dist/backend/dynamic-table/components/StaleDataBar.js.map +7 -0
  273. package/dist/backend/dynamic-table/components/TableDeleteDialog.js +48 -0
  274. package/dist/backend/dynamic-table/components/TableDeleteDialog.js.map +7 -0
  275. package/dist/backend/dynamic-table/components/TablePagination.js +69 -0
  276. package/dist/backend/dynamic-table/components/TablePagination.js.map +7 -0
  277. package/dist/backend/dynamic-table/components/TableSkeleton.js +43 -0
  278. package/dist/backend/dynamic-table/components/TableSkeleton.js.map +7 -0
  279. package/dist/backend/dynamic-table/components/ToolbarOverflow.js +204 -0
  280. package/dist/backend/dynamic-table/components/ToolbarOverflow.js.map +7 -0
  281. package/dist/backend/dynamic-table/components/ViewModeSwitch.js +50 -0
  282. package/dist/backend/dynamic-table/components/ViewModeSwitch.js.map +7 -0
  283. package/dist/backend/dynamic-table/components/VirtualRow.js +214 -0
  284. package/dist/backend/dynamic-table/components/VirtualRow.js.map +7 -0
  285. package/dist/backend/dynamic-table/components/editors.js +1483 -0
  286. package/dist/backend/dynamic-table/components/editors.js.map +7 -0
  287. package/dist/backend/dynamic-table/components/renderers.js +131 -0
  288. package/dist/backend/dynamic-table/components/renderers.js.map +7 -0
  289. package/dist/backend/dynamic-table/events/events.js +73 -0
  290. package/dist/backend/dynamic-table/events/events.js.map +7 -0
  291. package/dist/backend/dynamic-table/formula/check.js +243 -0
  292. package/dist/backend/dynamic-table/formula/check.js.map +7 -0
  293. package/dist/backend/dynamic-table/formula/evaluate.js +302 -0
  294. package/dist/backend/dynamic-table/formula/evaluate.js.map +7 -0
  295. package/dist/backend/dynamic-table/formula/functions.js +602 -0
  296. package/dist/backend/dynamic-table/formula/functions.js.map +7 -0
  297. package/dist/backend/dynamic-table/formula/index.js +47 -0
  298. package/dist/backend/dynamic-table/formula/index.js.map +7 -0
  299. package/dist/backend/dynamic-table/formula/parse.js +293 -0
  300. package/dist/backend/dynamic-table/formula/parse.js.map +7 -0
  301. package/dist/backend/dynamic-table/formula/types.js +24 -0
  302. package/dist/backend/dynamic-table/formula/types.js.map +7 -0
  303. package/dist/backend/dynamic-table/handlers/cellWrites.js +143 -0
  304. package/dist/backend/dynamic-table/handlers/cellWrites.js.map +7 -0
  305. package/dist/backend/dynamic-table/handlers/index.js +504 -0
  306. package/dist/backend/dynamic-table/handlers/index.js.map +7 -0
  307. package/dist/backend/dynamic-table/handlers/perspectiveHandlers.js +377 -0
  308. package/dist/backend/dynamic-table/handlers/perspectiveHandlers.js.map +7 -0
  309. package/dist/backend/dynamic-table/hooks/index.js +524 -0
  310. package/dist/backend/dynamic-table/hooks/index.js.map +7 -0
  311. package/dist/backend/dynamic-table/hooks/useAbbreviations.js +19 -0
  312. package/dist/backend/dynamic-table/hooks/useAbbreviations.js.map +7 -0
  313. package/dist/backend/dynamic-table/hooks/useAnnotations.js +115 -0
  314. package/dist/backend/dynamic-table/hooks/useAnnotations.js.map +7 -0
  315. package/dist/backend/dynamic-table/hooks/useColumnVirtualizer.js +155 -0
  316. package/dist/backend/dynamic-table/hooks/useColumnVirtualizer.js.map +7 -0
  317. package/dist/backend/dynamic-table/hooks/useColumnWidthPersistence.js +135 -0
  318. package/dist/backend/dynamic-table/hooks/useColumnWidthPersistence.js.map +7 -0
  319. package/dist/backend/dynamic-table/hooks/useDensityPreference.js +203 -0
  320. package/dist/backend/dynamic-table/hooks/useDensityPreference.js.map +7 -0
  321. package/dist/backend/dynamic-table/hooks/useDynamicTablePage.js +1005 -0
  322. package/dist/backend/dynamic-table/hooks/useDynamicTablePage.js.map +7 -0
  323. package/dist/backend/dynamic-table/hooks/useEscapeLayer.js +32 -0
  324. package/dist/backend/dynamic-table/hooks/useEscapeLayer.js.map +7 -0
  325. package/dist/backend/dynamic-table/hooks/useGrouping.js +256 -0
  326. package/dist/backend/dynamic-table/hooks/useGrouping.js.map +7 -0
  327. package/dist/backend/dynamic-table/hooks/useSuggestionFetch.js +63 -0
  328. package/dist/backend/dynamic-table/hooks/useSuggestionFetch.js.map +7 -0
  329. package/dist/backend/dynamic-table/hooks/useTableDateFormat.js +11 -0
  330. package/dist/backend/dynamic-table/hooks/useTableDateFormat.js.map +7 -0
  331. package/dist/backend/dynamic-table/index.js +476 -0
  332. package/dist/backend/dynamic-table/index.js.map +7 -0
  333. package/dist/backend/dynamic-table/registry/ContentRegistryContext.js +126 -0
  334. package/dist/backend/dynamic-table/registry/ContentRegistryContext.js.map +7 -0
  335. package/dist/backend/dynamic-table/registry/TableRegistryContext.js +43 -0
  336. package/dist/backend/dynamic-table/registry/TableRegistryContext.js.map +7 -0
  337. package/dist/backend/dynamic-table/registry/filterAccessibleTables.js +13 -0
  338. package/dist/backend/dynamic-table/registry/filterAccessibleTables.js.map +7 -0
  339. package/dist/backend/dynamic-table/registry/index.js +44 -0
  340. package/dist/backend/dynamic-table/registry/index.js.map +7 -0
  341. package/dist/backend/dynamic-table/registry/lazyTable.js +55 -0
  342. package/dist/backend/dynamic-table/registry/lazyTable.js.map +7 -0
  343. package/dist/backend/dynamic-table/registry/types.js +1 -0
  344. package/dist/backend/dynamic-table/registry/types.js.map +7 -0
  345. package/dist/backend/dynamic-table/registry/widgetCatalog.js +96 -0
  346. package/dist/backend/dynamic-table/registry/widgetCatalog.js.map +7 -0
  347. package/dist/backend/dynamic-table/server/aggregate.js +261 -0
  348. package/dist/backend/dynamic-table/server/aggregate.js.map +7 -0
  349. package/dist/backend/dynamic-table/server/dateWindows.js +105 -0
  350. package/dist/backend/dynamic-table/server/dateWindows.js.map +7 -0
  351. package/dist/backend/dynamic-table/server/filterParser.js +97 -0
  352. package/dist/backend/dynamic-table/server/filterParser.js.map +7 -0
  353. package/dist/backend/dynamic-table/server/filterSuggestions.js +70 -0
  354. package/dist/backend/dynamic-table/server/filterSuggestions.js.map +7 -0
  355. package/dist/backend/dynamic-table/server/index.js +47 -0
  356. package/dist/backend/dynamic-table/server/index.js.map +7 -0
  357. package/dist/backend/dynamic-table/server/makeDynamicTableRoute.js +270 -0
  358. package/dist/backend/dynamic-table/server/makeDynamicTableRoute.js.map +7 -0
  359. package/dist/backend/dynamic-table/split-view/ContentPicker.js +171 -0
  360. package/dist/backend/dynamic-table/split-view/ContentPicker.js.map +7 -0
  361. package/dist/backend/dynamic-table/split-view/EmptySlot.js +47 -0
  362. package/dist/backend/dynamic-table/split-view/EmptySlot.js.map +7 -0
  363. package/dist/backend/dynamic-table/split-view/SplitViewHost.js +863 -0
  364. package/dist/backend/dynamic-table/split-view/SplitViewHost.js.map +7 -0
  365. package/dist/backend/dynamic-table/split-view/WidgetPane.js +280 -0
  366. package/dist/backend/dynamic-table/split-view/WidgetPane.js.map +7 -0
  367. package/dist/backend/dynamic-table/split-view/WorkspaceFilterBar.js +524 -0
  368. package/dist/backend/dynamic-table/split-view/WorkspaceFilterBar.js.map +7 -0
  369. package/dist/backend/dynamic-table/split-view/index.js +61 -0
  370. package/dist/backend/dynamic-table/split-view/index.js.map +7 -0
  371. package/dist/backend/dynamic-table/split-view/layoutPersistence.js +91 -0
  372. package/dist/backend/dynamic-table/split-view/layoutPersistence.js.map +7 -0
  373. package/dist/backend/dynamic-table/split-view/sharedCriteria.js +62 -0
  374. package/dist/backend/dynamic-table/split-view/sharedCriteria.js.map +7 -0
  375. package/dist/backend/dynamic-table/split-view/types.js +426 -0
  376. package/dist/backend/dynamic-table/split-view/types.js.map +7 -0
  377. package/dist/backend/dynamic-table/split-view/useSplitViewLayouts.js +79 -0
  378. package/dist/backend/dynamic-table/split-view/useSplitViewLayouts.js.map +7 -0
  379. package/dist/backend/dynamic-table/store/index.js +611 -0
  380. package/dist/backend/dynamic-table/store/index.js.map +7 -0
  381. package/dist/backend/dynamic-table/styles/ContextMenu.css.js +2 -0
  382. package/dist/backend/dynamic-table/styles/DynamicTable.css.js +2 -0
  383. package/dist/backend/dynamic-table/styles/DynamicTable.v2.css.js +2 -0
  384. package/dist/backend/dynamic-table/styles/SearchBar.css.js +2 -0
  385. package/dist/backend/dynamic-table/styles/dateCell.css.js +2 -0
  386. package/dist/backend/dynamic-table/styles/density.css.js +2 -0
  387. package/dist/backend/dynamic-table/types/density.js +29 -0
  388. package/dist/backend/dynamic-table/types/density.js.map +7 -0
  389. package/dist/backend/dynamic-table/types/filters.js +124 -0
  390. package/dist/backend/dynamic-table/types/filters.js.map +7 -0
  391. package/dist/backend/dynamic-table/types/grouping.js +34 -0
  392. package/dist/backend/dynamic-table/types/grouping.js.map +7 -0
  393. package/dist/backend/dynamic-table/types/import.js +7 -0
  394. package/dist/backend/dynamic-table/types/import.js.map +7 -0
  395. package/dist/backend/dynamic-table/types/index.js +46 -0
  396. package/dist/backend/dynamic-table/types/index.js.map +7 -0
  397. package/dist/backend/dynamic-table/types/perspective.js +79 -0
  398. package/dist/backend/dynamic-table/types/perspective.js.map +7 -0
  399. package/dist/backend/dynamic-table/types/rollup.js +13 -0
  400. package/dist/backend/dynamic-table/types/rollup.js.map +7 -0
  401. package/dist/backend/dynamic-table/utils/abbreviations.js +228 -0
  402. package/dist/backend/dynamic-table/utils/abbreviations.js.map +7 -0
  403. package/dist/backend/dynamic-table/utils/anchoredPosition.js +21 -0
  404. package/dist/backend/dynamic-table/utils/anchoredPosition.js.map +7 -0
  405. package/dist/backend/dynamic-table/utils/badgeAutoFit.js +35 -0
  406. package/dist/backend/dynamic-table/utils/badgeAutoFit.js.map +7 -0
  407. package/dist/backend/dynamic-table/utils/clipboard.js +172 -0
  408. package/dist/backend/dynamic-table/utils/clipboard.js.map +7 -0
  409. package/dist/backend/dynamic-table/utils/coerceCellValue.js +52 -0
  410. package/dist/backend/dynamic-table/utils/coerceCellValue.js.map +7 -0
  411. package/dist/backend/dynamic-table/utils/colorAdjacency.js +27 -0
  412. package/dist/backend/dynamic-table/utils/colorAdjacency.js.map +7 -0
  413. package/dist/backend/dynamic-table/utils/columnFill.js +36 -0
  414. package/dist/backend/dynamic-table/utils/columnFill.js.map +7 -0
  415. package/dist/backend/dynamic-table/utils/columnSpacerStyle.js +21 -0
  416. package/dist/backend/dynamic-table/utils/columnSpacerStyle.js.map +7 -0
  417. package/dist/backend/dynamic-table/utils/columnWindow.js +293 -0
  418. package/dist/backend/dynamic-table/utils/columnWindow.js.map +7 -0
  419. package/dist/backend/dynamic-table/utils/conditionalFormat.js +188 -0
  420. package/dist/backend/dynamic-table/utils/conditionalFormat.js.map +7 -0
  421. package/dist/backend/dynamic-table/utils/exportTable.js +82 -0
  422. package/dist/backend/dynamic-table/utils/exportTable.js.map +7 -0
  423. package/dist/backend/dynamic-table/utils/fillGeometry.js +92 -0
  424. package/dist/backend/dynamic-table/utils/fillGeometry.js.map +7 -0
  425. package/dist/backend/dynamic-table/utils/fillPatterns.js +241 -0
  426. package/dist/backend/dynamic-table/utils/fillPatterns.js.map +7 -0
  427. package/dist/backend/dynamic-table/utils/formatAggregate.js +82 -0
  428. package/dist/backend/dynamic-table/utils/formatAggregate.js.map +7 -0
  429. package/dist/backend/dynamic-table/utils/formatDateCompact.js +508 -0
  430. package/dist/backend/dynamic-table/utils/formatDateCompact.js.map +7 -0
  431. package/dist/backend/dynamic-table/utils/formatLabelCompact.js +312 -0
  432. package/dist/backend/dynamic-table/utils/formatLabelCompact.js.map +7 -0
  433. package/dist/backend/dynamic-table/utils/formulaColumns.js +158 -0
  434. package/dist/backend/dynamic-table/utils/formulaColumns.js.map +7 -0
  435. package/dist/backend/dynamic-table/utils/importMapping.js +118 -0
  436. package/dist/backend/dynamic-table/utils/importMapping.js.map +7 -0
  437. package/dist/backend/dynamic-table/utils/importParse.js +121 -0
  438. package/dist/backend/dynamic-table/utils/importParse.js.map +7 -0
  439. package/dist/backend/dynamic-table/utils/lookupColumns.js +28 -0
  440. package/dist/backend/dynamic-table/utils/lookupColumns.js.map +7 -0
  441. package/dist/backend/dynamic-table/utils/perspectiveTransforms.js +191 -0
  442. package/dist/backend/dynamic-table/utils/perspectiveTransforms.js.map +7 -0
  443. package/dist/backend/dynamic-table/utils/rollupColumns.js +81 -0
  444. package/dist/backend/dynamic-table/utils/rollupColumns.js.map +7 -0
  445. package/dist/backend/dynamic-table/validators/index.js +36 -0
  446. package/dist/backend/dynamic-table/validators/index.js.map +7 -0
  447. package/dist/backend/fields/registry.generated.js +8 -0
  448. package/dist/backend/fields/registry.generated.js.map +7 -0
  449. package/dist/backend/fields/registry.js +29 -0
  450. package/dist/backend/fields/registry.js.map +7 -0
  451. package/dist/backend/filters/AdvancedFilterBuilder.js +280 -0
  452. package/dist/backend/filters/AdvancedFilterBuilder.js.map +7 -0
  453. package/dist/backend/forms/ActionsDropdown.js +160 -0
  454. package/dist/backend/forms/ActionsDropdown.js.map +7 -0
  455. package/dist/backend/forms/FormActionButtons.js +59 -0
  456. package/dist/backend/forms/FormActionButtons.js.map +7 -0
  457. package/dist/backend/forms/FormFooter.js +18 -0
  458. package/dist/backend/forms/FormFooter.js.map +7 -0
  459. package/dist/backend/forms/FormHeader.js +125 -0
  460. package/dist/backend/forms/FormHeader.js.map +7 -0
  461. package/dist/backend/forms/index.js +11 -0
  462. package/dist/backend/forms/index.js.map +7 -0
  463. package/dist/backend/hooks/useAdvancedFilter.js +64 -0
  464. package/dist/backend/hooks/useAdvancedFilter.js.map +7 -0
  465. package/dist/backend/indexes/PartialIndexBanner.js +74 -0
  466. package/dist/backend/indexes/PartialIndexBanner.js.map +7 -0
  467. package/dist/backend/indexes/store.js +62 -0
  468. package/dist/backend/indexes/store.js.map +7 -0
  469. package/dist/backend/injection/ComponentOverrideProvider.js +55 -0
  470. package/dist/backend/injection/ComponentOverrideProvider.js.map +7 -0
  471. package/dist/backend/injection/InjectedField.js +166 -0
  472. package/dist/backend/injection/InjectedField.js.map +7 -0
  473. package/dist/backend/injection/InjectionSpot.js +323 -0
  474. package/dist/backend/injection/InjectionSpot.js.map +7 -0
  475. package/dist/backend/injection/InjectionWizard.js +256 -0
  476. package/dist/backend/injection/InjectionWizard.js.map +7 -0
  477. package/dist/backend/injection/PageInjectionBoundary.js +26 -0
  478. package/dist/backend/injection/PageInjectionBoundary.js.map +7 -0
  479. package/dist/backend/injection/SseEventIndicator.js +8 -0
  480. package/dist/backend/injection/SseEventIndicator.js.map +7 -0
  481. package/dist/backend/injection/StatusBadgeInjectionSpot.js +34 -0
  482. package/dist/backend/injection/StatusBadgeInjectionSpot.js.map +7 -0
  483. package/dist/backend/injection/StatusBadgeRenderer.js +58 -0
  484. package/dist/backend/injection/StatusBadgeRenderer.js.map +7 -0
  485. package/dist/backend/injection/WidgetSharedState.js +49 -0
  486. package/dist/backend/injection/WidgetSharedState.js.map +7 -0
  487. package/dist/backend/injection/WizardInjectionSpot.js +30 -0
  488. package/dist/backend/injection/WizardInjectionSpot.js.map +7 -0
  489. package/dist/backend/injection/eventBridge.js +129 -0
  490. package/dist/backend/injection/eventBridge.js.map +7 -0
  491. package/dist/backend/injection/helpers.js +26 -0
  492. package/dist/backend/injection/helpers.js.map +7 -0
  493. package/dist/backend/injection/mergeMenuItems.js +49 -0
  494. package/dist/backend/injection/mergeMenuItems.js.map +7 -0
  495. package/dist/backend/injection/mutationEvents.js +20 -0
  496. package/dist/backend/injection/mutationEvents.js.map +7 -0
  497. package/dist/backend/injection/resolveInjectedIcon.js +23 -0
  498. package/dist/backend/injection/resolveInjectedIcon.js.map +7 -0
  499. package/dist/backend/injection/spotIds.js +72 -0
  500. package/dist/backend/injection/spotIds.js.map +7 -0
  501. package/dist/backend/injection/useAppEvent.js +35 -0
  502. package/dist/backend/injection/useAppEvent.js.map +7 -0
  503. package/dist/backend/injection/useGuardedMutation.js +71 -0
  504. package/dist/backend/injection/useGuardedMutation.js.map +7 -0
  505. package/dist/backend/injection/useInjectedMenuItems.js +49 -0
  506. package/dist/backend/injection/useInjectedMenuItems.js.map +7 -0
  507. package/dist/backend/injection/useInjectionDataWidgets.js +36 -0
  508. package/dist/backend/injection/useInjectionDataWidgets.js.map +7 -0
  509. package/dist/backend/injection/useOperationProgress.js +64 -0
  510. package/dist/backend/injection/useOperationProgress.js.map +7 -0
  511. package/dist/backend/injection/useRegisteredComponent.js +89 -0
  512. package/dist/backend/injection/useRegisteredComponent.js.map +7 -0
  513. package/dist/backend/injection/useWidgetSharedState.js +26 -0
  514. package/dist/backend/injection/useWidgetSharedState.js.map +7 -0
  515. package/dist/backend/injection/visibility-utils.js +29 -0
  516. package/dist/backend/injection/visibility-utils.js.map +7 -0
  517. package/dist/backend/injection/widgetRegistry.js +55 -0
  518. package/dist/backend/injection/widgetRegistry.js.map +7 -0
  519. package/dist/backend/inputs/ComboboxInput.js +228 -0
  520. package/dist/backend/inputs/ComboboxInput.js.map +7 -0
  521. package/dist/backend/inputs/DatePicker.js +148 -0
  522. package/dist/backend/inputs/DatePicker.js.map +7 -0
  523. package/dist/backend/inputs/DateTimePicker.js +188 -0
  524. package/dist/backend/inputs/DateTimePicker.js.map +7 -0
  525. package/dist/backend/inputs/EventPatternInput.js +38 -0
  526. package/dist/backend/inputs/EventPatternInput.js.map +7 -0
  527. package/dist/backend/inputs/EventSelect.js +115 -0
  528. package/dist/backend/inputs/EventSelect.js.map +7 -0
  529. package/dist/backend/inputs/LookupSelect.js +197 -0
  530. package/dist/backend/inputs/LookupSelect.js.map +7 -0
  531. package/dist/backend/inputs/PhoneNumberField.js +133 -0
  532. package/dist/backend/inputs/PhoneNumberField.js.map +7 -0
  533. package/dist/backend/inputs/SwitchableMarkdownInput.js +73 -0
  534. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +7 -0
  535. package/dist/backend/inputs/TagsInput.js +258 -0
  536. package/dist/backend/inputs/TagsInput.js.map +7 -0
  537. package/dist/backend/inputs/TimeInput.js +132 -0
  538. package/dist/backend/inputs/TimeInput.js.map +7 -0
  539. package/dist/backend/inputs/TimePicker.js +105 -0
  540. package/dist/backend/inputs/TimePicker.js.map +7 -0
  541. package/dist/backend/inputs/index.js +12 -0
  542. package/dist/backend/inputs/index.js.map +7 -0
  543. package/dist/backend/markdown/MarkdownContent.js +20 -0
  544. package/dist/backend/markdown/MarkdownContent.js.map +7 -0
  545. package/dist/backend/markdown/index.js +3 -0
  546. package/dist/backend/markdown/index.js.map +7 -0
  547. package/dist/backend/markdown/useMarkdownRemarkPlugins.js +33 -0
  548. package/dist/backend/markdown/useMarkdownRemarkPlugins.js.map +7 -0
  549. package/dist/backend/messages/MessageComposer.js +158 -0
  550. package/dist/backend/messages/MessageComposer.js.map +7 -0
  551. package/dist/backend/messages/MessageObjectDetail.js +81 -0
  552. package/dist/backend/messages/MessageObjectDetail.js.map +7 -0
  553. package/dist/backend/messages/MessageObjectPreview.js +48 -0
  554. package/dist/backend/messages/MessageObjectPreview.js.map +7 -0
  555. package/dist/backend/messages/MessageObjectRecordPicker.js +89 -0
  556. package/dist/backend/messages/MessageObjectRecordPicker.js.map +7 -0
  557. package/dist/backend/messages/MessagePrioritySelector.js +74 -0
  558. package/dist/backend/messages/MessagePrioritySelector.js.map +7 -0
  559. package/dist/backend/messages/MessagesIcon.js +22 -0
  560. package/dist/backend/messages/MessagesIcon.js.map +7 -0
  561. package/dist/backend/messages/SendObjectMessageDialog.js +65 -0
  562. package/dist/backend/messages/SendObjectMessageDialog.js.map +7 -0
  563. package/dist/backend/messages/index.js +17 -0
  564. package/dist/backend/messages/index.js.map +7 -0
  565. package/dist/backend/messages/message-compose-form-groups.js +333 -0
  566. package/dist/backend/messages/message-compose-form-groups.js.map +7 -0
  567. package/dist/backend/messages/message-composer.types.js +1 -0
  568. package/dist/backend/messages/message-composer.types.js.map +7 -0
  569. package/dist/backend/messages/message-priority.js +40 -0
  570. package/dist/backend/messages/message-priority.js.map +7 -0
  571. package/dist/backend/messages/useMessageCompose.js +488 -0
  572. package/dist/backend/messages/useMessageCompose.js.map +7 -0
  573. package/dist/backend/messages/useMessageComposeOperations.js +163 -0
  574. package/dist/backend/messages/useMessageComposeOperations.js.map +7 -0
  575. package/dist/backend/messages/useMessagesPoll.js +79 -0
  576. package/dist/backend/messages/useMessagesPoll.js.map +7 -0
  577. package/dist/backend/notifications/NotificationBell.js +83 -0
  578. package/dist/backend/notifications/NotificationBell.js.map +7 -0
  579. package/dist/backend/notifications/NotificationCountBadge.js +9 -0
  580. package/dist/backend/notifications/NotificationCountBadge.js.map +7 -0
  581. package/dist/backend/notifications/NotificationDispatcher.js +165 -0
  582. package/dist/backend/notifications/NotificationDispatcher.js.map +7 -0
  583. package/dist/backend/notifications/NotificationItem.js +187 -0
  584. package/dist/backend/notifications/NotificationItem.js.map +7 -0
  585. package/dist/backend/notifications/NotificationPanel.js +146 -0
  586. package/dist/backend/notifications/NotificationPanel.js.map +7 -0
  587. package/dist/backend/notifications/index.js +19 -0
  588. package/dist/backend/notifications/index.js.map +7 -0
  589. package/dist/backend/notifications/useNotificationActions.js +124 -0
  590. package/dist/backend/notifications/useNotificationActions.js.map +7 -0
  591. package/dist/backend/notifications/useNotificationEffect.js +13 -0
  592. package/dist/backend/notifications/useNotificationEffect.js.map +7 -0
  593. package/dist/backend/notifications/useNotifications.js +11 -0
  594. package/dist/backend/notifications/useNotifications.js.map +7 -0
  595. package/dist/backend/notifications/useNotificationsPoll.js +135 -0
  596. package/dist/backend/notifications/useNotificationsPoll.js.map +7 -0
  597. package/dist/backend/notifications/useNotificationsSse.js +173 -0
  598. package/dist/backend/notifications/useNotificationsSse.js.map +7 -0
  599. package/dist/backend/operations/LastOperationBanner.js +80 -0
  600. package/dist/backend/operations/LastOperationBanner.js.map +7 -0
  601. package/dist/backend/operations/store.js +183 -0
  602. package/dist/backend/operations/store.js.map +7 -0
  603. package/dist/backend/progress/ProgressTopBar.js +129 -0
  604. package/dist/backend/progress/ProgressTopBar.js.map +7 -0
  605. package/dist/backend/progress/useProgress.js +11 -0
  606. package/dist/backend/progress/useProgress.js.map +7 -0
  607. package/dist/backend/progress/useProgressPoll.js +69 -0
  608. package/dist/backend/progress/useProgressPoll.js.map +7 -0
  609. package/dist/backend/progress/useProgressSse.js +104 -0
  610. package/dist/backend/progress/useProgressSse.js.map +7 -0
  611. package/dist/backend/schedule/ScheduleAgenda.js +107 -0
  612. package/dist/backend/schedule/ScheduleAgenda.js.map +7 -0
  613. package/dist/backend/schedule/ScheduleGrid.js +107 -0
  614. package/dist/backend/schedule/ScheduleGrid.js.map +7 -0
  615. package/dist/backend/schedule/ScheduleToolbar.js +166 -0
  616. package/dist/backend/schedule/ScheduleToolbar.js.map +7 -0
  617. package/dist/backend/schedule/ScheduleView.js +168 -0
  618. package/dist/backend/schedule/ScheduleView.js.map +7 -0
  619. package/dist/backend/schedule/index.js +6 -0
  620. package/dist/backend/schedule/index.js.map +7 -0
  621. package/dist/backend/schedule/recurrence.js +83 -0
  622. package/dist/backend/schedule/recurrence.js.map +7 -0
  623. package/dist/backend/schedule/types.js +1 -0
  624. package/dist/backend/schedule/types.js.map +7 -0
  625. package/dist/backend/section-page/SectionNav.js +104 -0
  626. package/dist/backend/section-page/SectionNav.js.map +7 -0
  627. package/dist/backend/section-page/SectionPage.js +33 -0
  628. package/dist/backend/section-page/SectionPage.js.map +7 -0
  629. package/dist/backend/section-page/index.js +7 -0
  630. package/dist/backend/section-page/index.js.map +7 -0
  631. package/dist/backend/section-page/types.js +1 -0
  632. package/dist/backend/section-page/types.js.map +7 -0
  633. package/dist/backend/settings/SettingsNavigation.js +43 -0
  634. package/dist/backend/settings/SettingsNavigation.js.map +7 -0
  635. package/dist/backend/settings/SettingsPageWrapper.js +66 -0
  636. package/dist/backend/settings/SettingsPageWrapper.js.map +7 -0
  637. package/dist/backend/settings/index.js +6 -0
  638. package/dist/backend/settings/index.js.map +7 -0
  639. package/dist/backend/upgrades/UpgradeActionBanner.js +91 -0
  640. package/dist/backend/upgrades/UpgradeActionBanner.js.map +7 -0
  641. package/dist/backend/utils/api.js +178 -0
  642. package/dist/backend/utils/api.js.map +7 -0
  643. package/dist/backend/utils/apiCall.js +68 -0
  644. package/dist/backend/utils/apiCall.js.map +7 -0
  645. package/dist/backend/utils/crud.js +126 -0
  646. package/dist/backend/utils/crud.js.map +7 -0
  647. package/dist/backend/utils/customFieldColumns.js +100 -0
  648. package/dist/backend/utils/customFieldColumns.js.map +7 -0
  649. package/dist/backend/utils/customFieldDefs.js +143 -0
  650. package/dist/backend/utils/customFieldDefs.js.map +7 -0
  651. package/dist/backend/utils/customFieldFilters.js +151 -0
  652. package/dist/backend/utils/customFieldFilters.js.map +7 -0
  653. package/dist/backend/utils/customFieldForms.js +169 -0
  654. package/dist/backend/utils/customFieldForms.js.map +7 -0
  655. package/dist/backend/utils/customFieldRelationDisplay.js +239 -0
  656. package/dist/backend/utils/customFieldRelationDisplay.js.map +7 -0
  657. package/dist/backend/utils/customFieldValues.js +26 -0
  658. package/dist/backend/utils/customFieldValues.js.map +7 -0
  659. package/dist/backend/utils/flash.js +16 -0
  660. package/dist/backend/utils/flash.js.map +7 -0
  661. package/dist/backend/utils/nav.js +280 -0
  662. package/dist/backend/utils/nav.js.map +7 -0
  663. package/dist/backend/utils/richTextSanitizer.js +11 -0
  664. package/dist/backend/utils/richTextSanitizer.js.map +7 -0
  665. package/dist/backend/utils/scopedHeaderStack.js +44 -0
  666. package/dist/backend/utils/scopedHeaderStack.js.map +7 -0
  667. package/dist/backend/utils/serverErrors.js +237 -0
  668. package/dist/backend/utils/serverErrors.js.map +7 -0
  669. package/dist/backend/utils/useAutoDiscoveredFields.js +96 -0
  670. package/dist/backend/utils/useAutoDiscoveredFields.js.map +7 -0
  671. package/dist/backend/version-history/VersionHistoryAction.js +67 -0
  672. package/dist/backend/version-history/VersionHistoryAction.js.map +7 -0
  673. package/dist/backend/version-history/VersionHistoryDetail.js +57 -0
  674. package/dist/backend/version-history/VersionHistoryDetail.js.map +7 -0
  675. package/dist/backend/version-history/VersionHistoryPanel.js +270 -0
  676. package/dist/backend/version-history/VersionHistoryPanel.js.map +7 -0
  677. package/dist/backend/version-history/index.js +13 -0
  678. package/dist/backend/version-history/index.js.map +7 -0
  679. package/dist/backend/version-history/labels.js +52 -0
  680. package/dist/backend/version-history/labels.js.map +7 -0
  681. package/dist/backend/version-history/types.js +1 -0
  682. package/dist/backend/version-history/types.js.map +7 -0
  683. package/dist/backend/version-history/useAuditPermissions.js +72 -0
  684. package/dist/backend/version-history/useAuditPermissions.js.map +7 -0
  685. package/dist/backend/version-history/useVersionHistory.js +141 -0
  686. package/dist/backend/version-history/useVersionHistory.js.map +7 -0
  687. package/dist/backend/views/NewViewForm.js +56 -0
  688. package/dist/backend/views/NewViewForm.js.map +7 -0
  689. package/dist/backend/views/ShareForm.js +49 -0
  690. package/dist/backend/views/ShareForm.js.map +7 -0
  691. package/dist/backend/views/ViewChip.js +112 -0
  692. package/dist/backend/views/ViewChip.js.map +7 -0
  693. package/dist/backend/views/ViewChipMenu.js +89 -0
  694. package/dist/backend/views/ViewChipMenu.js.map +7 -0
  695. package/dist/backend/views/types.js +1 -0
  696. package/dist/backend/views/types.js.map +7 -0
  697. package/dist/frontend/AuthFooter.js +23 -0
  698. package/dist/frontend/AuthFooter.js.map +7 -0
  699. package/dist/frontend/LanguageSwitcher.js +57 -0
  700. package/dist/frontend/LanguageSwitcher.js.map +7 -0
  701. package/dist/frontend/Layout.js +14 -0
  702. package/dist/frontend/Layout.js.map +7 -0
  703. package/dist/hooks/useIsMobile.js +21 -0
  704. package/dist/hooks/useIsMobile.js.map +7 -0
  705. package/dist/index.js +40 -0
  706. package/dist/index.js.map +7 -0
  707. package/dist/portal/PortalContext.js +113 -0
  708. package/dist/portal/PortalContext.js.map +7 -0
  709. package/dist/portal/PortalLayoutProvider.js +5 -0
  710. package/dist/portal/PortalLayoutProvider.js.map +7 -0
  711. package/dist/portal/PortalLayoutShell.js +44 -0
  712. package/dist/portal/PortalLayoutShell.js.map +7 -0
  713. package/dist/portal/PortalShell.js +229 -0
  714. package/dist/portal/PortalShell.js.map +7 -0
  715. package/dist/portal/components/PortalCard.js +31 -0
  716. package/dist/portal/components/PortalCard.js.map +7 -0
  717. package/dist/portal/components/PortalEmptyState.js +16 -0
  718. package/dist/portal/components/PortalEmptyState.js.map +7 -0
  719. package/dist/portal/components/PortalFeatureCard.js +21 -0
  720. package/dist/portal/components/PortalFeatureCard.js.map +7 -0
  721. package/dist/portal/components/PortalNotificationBell.js +67 -0
  722. package/dist/portal/components/PortalNotificationBell.js.map +7 -0
  723. package/dist/portal/components/PortalNotificationPanel.js +158 -0
  724. package/dist/portal/components/PortalNotificationPanel.js.map +7 -0
  725. package/dist/portal/components/PortalPageHeader.js +16 -0
  726. package/dist/portal/components/PortalPageHeader.js.map +7 -0
  727. package/dist/portal/components/index.js +18 -0
  728. package/dist/portal/components/index.js.map +7 -0
  729. package/dist/portal/hooks/useCustomerAuth.js +69 -0
  730. package/dist/portal/hooks/useCustomerAuth.js.map +7 -0
  731. package/dist/portal/hooks/usePortalAppEvent.js +26 -0
  732. package/dist/portal/hooks/usePortalAppEvent.js.map +7 -0
  733. package/dist/portal/hooks/usePortalDashboardWidgets.js +37 -0
  734. package/dist/portal/hooks/usePortalDashboardWidgets.js.map +7 -0
  735. package/dist/portal/hooks/usePortalEventBridge.js +129 -0
  736. package/dist/portal/hooks/usePortalEventBridge.js.map +7 -0
  737. package/dist/portal/hooks/usePortalInjectedMenuItems.js +88 -0
  738. package/dist/portal/hooks/usePortalInjectedMenuItems.js.map +7 -0
  739. package/dist/portal/hooks/usePortalNotifications.js +85 -0
  740. package/dist/portal/hooks/usePortalNotifications.js.map +7 -0
  741. package/dist/portal/hooks/useTenantContext.js +53 -0
  742. package/dist/portal/hooks/useTenantContext.js.map +7 -0
  743. package/dist/primitives/DataLoader.js +68 -0
  744. package/dist/primitives/DataLoader.js.map +7 -0
  745. package/dist/primitives/ErrorNotice.js +14 -0
  746. package/dist/primitives/ErrorNotice.js.map +7 -0
  747. package/dist/primitives/Notice.js +55 -0
  748. package/dist/primitives/Notice.js.map +7 -0
  749. package/dist/primitives/alert.js +38 -0
  750. package/dist/primitives/alert.js.map +7 -0
  751. package/dist/primitives/badge.js +33 -0
  752. package/dist/primitives/badge.js.map +7 -0
  753. package/dist/primitives/button.js +45 -0
  754. package/dist/primitives/button.js.map +7 -0
  755. package/dist/primitives/calendar.js +62 -0
  756. package/dist/primitives/calendar.js.map +7 -0
  757. package/dist/primitives/card.js +91 -0
  758. package/dist/primitives/card.js.map +7 -0
  759. package/dist/primitives/checkbox.js +28 -0
  760. package/dist/primitives/checkbox.js.map +7 -0
  761. package/dist/primitives/dialog.js +101 -0
  762. package/dist/primitives/dialog.js.map +7 -0
  763. package/dist/primitives/form-field.js +78 -0
  764. package/dist/primitives/form-field.js.map +7 -0
  765. package/dist/primitives/icon-button.js +40 -0
  766. package/dist/primitives/icon-button.js.map +7 -0
  767. package/dist/primitives/input.js +22 -0
  768. package/dist/primitives/input.js.map +7 -0
  769. package/dist/primitives/label.js +21 -0
  770. package/dist/primitives/label.js.map +7 -0
  771. package/dist/primitives/popover.js +36 -0
  772. package/dist/primitives/popover.js.map +7 -0
  773. package/dist/primitives/primitives.stories.js +187 -0
  774. package/dist/primitives/primitives.stories.js.map +7 -0
  775. package/dist/primitives/progress.js +28 -0
  776. package/dist/primitives/progress.js.map +7 -0
  777. package/dist/primitives/separator.js +9 -0
  778. package/dist/primitives/separator.js.map +7 -0
  779. package/dist/primitives/sheet.js +103 -0
  780. package/dist/primitives/sheet.js.map +7 -0
  781. package/dist/primitives/spinner.js +24 -0
  782. package/dist/primitives/spinner.js.map +7 -0
  783. package/dist/primitives/status-badge.js +38 -0
  784. package/dist/primitives/status-badge.js.map +7 -0
  785. package/dist/primitives/switch.js +80 -0
  786. package/dist/primitives/switch.js.map +7 -0
  787. package/dist/primitives/table.js +29 -0
  788. package/dist/primitives/table.js.map +7 -0
  789. package/dist/primitives/tabs.js +90 -0
  790. package/dist/primitives/tabs.js.map +7 -0
  791. package/dist/primitives/textarea.js +21 -0
  792. package/dist/primitives/textarea.js.map +7 -0
  793. package/dist/primitives/tooltip.js +60 -0
  794. package/dist/primitives/tooltip.js.map +7 -0
  795. package/dist/primitives-v2/Alert.js +69 -0
  796. package/dist/primitives-v2/Alert.js.map +7 -0
  797. package/dist/primitives-v2/Alert.stories.js +42 -0
  798. package/dist/primitives-v2/Alert.stories.js.map +7 -0
  799. package/dist/primitives-v2/Avatar.js +93 -0
  800. package/dist/primitives-v2/Avatar.js.map +7 -0
  801. package/dist/primitives-v2/Avatar.stories.js +57 -0
  802. package/dist/primitives-v2/Avatar.stories.js.map +7 -0
  803. package/dist/primitives-v2/Badge.js +50 -0
  804. package/dist/primitives-v2/Badge.js.map +7 -0
  805. package/dist/primitives-v2/Badge.stories.js +66 -0
  806. package/dist/primitives-v2/Badge.stories.js.map +7 -0
  807. package/dist/primitives-v2/Breadcrumb.js +53 -0
  808. package/dist/primitives-v2/Breadcrumb.js.map +7 -0
  809. package/dist/primitives-v2/Breadcrumb.stories.js +70 -0
  810. package/dist/primitives-v2/Breadcrumb.stories.js.map +7 -0
  811. package/dist/primitives-v2/Button.js +85 -0
  812. package/dist/primitives-v2/Button.js.map +7 -0
  813. package/dist/primitives-v2/Button.stories.js +48 -0
  814. package/dist/primitives-v2/Button.stories.js.map +7 -0
  815. package/dist/primitives-v2/Checkbox.js +65 -0
  816. package/dist/primitives-v2/Checkbox.js.map +7 -0
  817. package/dist/primitives-v2/Checkbox.stories.js +31 -0
  818. package/dist/primitives-v2/Checkbox.stories.js.map +7 -0
  819. package/dist/primitives-v2/IconButton.js +13 -0
  820. package/dist/primitives-v2/IconButton.js.map +7 -0
  821. package/dist/primitives-v2/IconButton.stories.js +37 -0
  822. package/dist/primitives-v2/IconButton.stories.js.map +7 -0
  823. package/dist/primitives-v2/Input.js +74 -0
  824. package/dist/primitives-v2/Input.js.map +7 -0
  825. package/dist/primitives-v2/Input.stories.js +56 -0
  826. package/dist/primitives-v2/Input.stories.js.map +7 -0
  827. package/dist/primitives-v2/NavGroup.js +54 -0
  828. package/dist/primitives-v2/NavGroup.js.map +7 -0
  829. package/dist/primitives-v2/NavGroup.stories.js +39 -0
  830. package/dist/primitives-v2/NavGroup.stories.js.map +7 -0
  831. package/dist/primitives-v2/NavItem.js +61 -0
  832. package/dist/primitives-v2/NavItem.js.map +7 -0
  833. package/dist/primitives-v2/NavItem.stories.js +56 -0
  834. package/dist/primitives-v2/NavItem.stories.js.map +7 -0
  835. package/dist/primitives-v2/SearchInput.js +93 -0
  836. package/dist/primitives-v2/SearchInput.js.map +7 -0
  837. package/dist/primitives-v2/SearchInput.stories.js +41 -0
  838. package/dist/primitives-v2/SearchInput.stories.js.map +7 -0
  839. package/dist/primitives-v2/Sidebar.js +48 -0
  840. package/dist/primitives-v2/Sidebar.js.map +7 -0
  841. package/dist/primitives-v2/Sidebar.stories.js +76 -0
  842. package/dist/primitives-v2/Sidebar.stories.js.map +7 -0
  843. package/dist/primitives-v2/Tab.js +78 -0
  844. package/dist/primitives-v2/Tab.js.map +7 -0
  845. package/dist/primitives-v2/Tab.stories.js +51 -0
  846. package/dist/primitives-v2/Tab.stories.js.map +7 -0
  847. package/dist/primitives-v2/Tag.js +61 -0
  848. package/dist/primitives-v2/Tag.js.map +7 -0
  849. package/dist/primitives-v2/Tag.stories.js +121 -0
  850. package/dist/primitives-v2/Tag.stories.js.map +7 -0
  851. package/dist/primitives-v2/Toggle.js +72 -0
  852. package/dist/primitives-v2/Toggle.js.map +7 -0
  853. package/dist/primitives-v2/Toggle.stories.js +34 -0
  854. package/dist/primitives-v2/Toggle.stories.js.map +7 -0
  855. package/dist/primitives-v2/Topbar.js +24 -0
  856. package/dist/primitives-v2/Topbar.js.map +7 -0
  857. package/dist/primitives-v2/Topbar.stories.js +58 -0
  858. package/dist/primitives-v2/Topbar.stories.js.map +7 -0
  859. package/dist/primitives-v2/index.js +41 -0
  860. package/dist/primitives-v2/index.js.map +7 -0
  861. package/dist/primitives-v2/utils.js +7 -0
  862. package/dist/primitives-v2/utils.js.map +7 -0
  863. package/dist/theme/BrandThemeProvider.js +49 -0
  864. package/dist/theme/BrandThemeProvider.js.map +7 -0
  865. package/dist/theme/QueryProvider.js +44 -0
  866. package/dist/theme/QueryProvider.js.map +7 -0
  867. package/dist/theme/ThemeProvider.js +96 -0
  868. package/dist/theme/ThemeProvider.js.map +7 -0
  869. package/dist/theme/ThemeToggle.js +88 -0
  870. package/dist/theme/ThemeToggle.js.map +7 -0
  871. package/dist/theme/index.js +12 -0
  872. package/dist/theme/index.js.map +7 -0
  873. package/dist/theme/m3.css.js +2 -0
  874. package/dist/theme/tokens.v2.css.js +2 -0
  875. package/dist/theme/typography.css.js +2 -0
  876. package/dist/theme/typography.stories.js +123 -0
  877. package/dist/theme/typography.stories.js.map +7 -0
  878. package/dist/theme/typography.tokens.js +281 -0
  879. package/dist/theme/typography.tokens.js.map +7 -0
  880. package/dist/types/react-big-calendar.d.js +1 -0
  881. package/dist/types/react-big-calendar.d.js.map +7 -0
  882. package/package.json +411 -0
  883. package/src/backend/AppShell.tsx +1606 -0
  884. package/src/backend/BackendChromeProvider.tsx +99 -0
  885. package/src/backend/BackendPageSurface.tsx +57 -0
  886. package/src/backend/CollapsibleNavSection.tsx +201 -0
  887. package/src/backend/ContextHelp.tsx +40 -0
  888. package/src/backend/CrudForm.tsx +3796 -0
  889. package/src/backend/DataTable.tsx +2584 -0
  890. package/src/backend/EmptyState.tsx +65 -0
  891. package/src/backend/FilterBar.tsx +171 -0
  892. package/src/backend/FilterOverlay.tsx +388 -0
  893. package/src/backend/FlashMessages.tsx +189 -0
  894. package/src/backend/HtmlRichTextEditor.tsx +124 -0
  895. package/src/backend/IntegrationsButton.tsx +29 -0
  896. package/src/backend/JsonBuilder.tsx +359 -0
  897. package/src/backend/JsonDisplay.tsx +258 -0
  898. package/src/backend/NextStepCallout.tsx +134 -0
  899. package/src/backend/Page.tsx +30 -0
  900. package/src/backend/PerspectiveSidebar.tsx +506 -0
  901. package/src/backend/ProfileDropdown.tsx +369 -0
  902. package/src/backend/RowActions.tsx +158 -0
  903. package/src/backend/SectionHeader.tsx +119 -0
  904. package/src/backend/SettingsButton.tsx +28 -0
  905. package/src/backend/Sidebar.stories.tsx +183 -0
  906. package/src/backend/Sidebar.tsx +737 -0
  907. package/src/backend/TruncatedCell.tsx +133 -0
  908. package/src/backend/UserMenu.tsx +153 -0
  909. package/src/backend/ValueIcons.tsx +48 -0
  910. package/src/backend/WebhookSetupGuide.tsx +127 -0
  911. package/src/backend/__tests__/AppShell.test.tsx +392 -0
  912. package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +180 -0
  913. package/src/backend/__tests__/CrudForm.datetime.test.tsx +95 -0
  914. package/src/backend/__tests__/CrudForm.defaults.test.tsx +195 -0
  915. package/src/backend/__tests__/CrudForm.navigation.test.tsx +156 -0
  916. package/src/backend/__tests__/CrudForm.render.test.tsx +146 -0
  917. package/src/backend/__tests__/CrudForm.transformData.test.tsx +231 -0
  918. package/src/backend/__tests__/CrudForm.unsavedNavigation.test.tsx +145 -0
  919. package/src/backend/__tests__/CrudForm.validation.test.tsx +178 -0
  920. package/src/backend/__tests__/CustomDataSection.test.tsx +465 -0
  921. package/src/backend/__tests__/DataTable.extensions.test.tsx +339 -0
  922. package/src/backend/__tests__/DataTable.namespaces.test.ts +32 -0
  923. package/src/backend/__tests__/DataTable.render.test.tsx +48 -0
  924. package/src/backend/__tests__/FieldDefinitionsEditor.test.tsx +64 -0
  925. package/src/backend/__tests__/FlashMessages.test.tsx +105 -0
  926. package/src/backend/__tests__/FormHeader.test.tsx +81 -0
  927. package/src/backend/__tests__/NotificationDispatcher.test.ts +172 -0
  928. package/src/backend/__tests__/VersionHistoryPanel.test.tsx +109 -0
  929. package/src/backend/__tests__/component-replacement.test.tsx +270 -0
  930. package/src/backend/__tests__/custom-field-filters.test.ts +72 -0
  931. package/src/backend/__tests__/custom-field-forms.test.ts +54 -0
  932. package/src/backend/__tests__/nav-utils.test.ts +175 -0
  933. package/src/backend/__tests__/serverErrors.test.ts +83 -0
  934. package/src/backend/__tests__/useAuditPermissions.test.tsx +51 -0
  935. package/src/backend/__tests__/useNotifications.strategy.test.ts +58 -0
  936. package/src/backend/__tests__/useProgress.strategy.test.ts +62 -0
  937. package/src/backend/charts/BarChart.tsx +208 -0
  938. package/src/backend/charts/ChartUtils.tsx +196 -0
  939. package/src/backend/charts/ComboChart.stories.tsx +103 -0
  940. package/src/backend/charts/ComboChart.tsx +350 -0
  941. package/src/backend/charts/KpiCard.tsx +155 -0
  942. package/src/backend/charts/LineChart.tsx +207 -0
  943. package/src/backend/charts/PieChart.tsx +157 -0
  944. package/src/backend/charts/ScatterChart.tsx +498 -0
  945. package/src/backend/charts/TopNTable.tsx +141 -0
  946. package/src/backend/charts/WaterfallChart.tsx +213 -0
  947. package/src/backend/charts/index.ts +21 -0
  948. package/src/backend/columns/ColumnChooserPanel.tsx +293 -0
  949. package/src/backend/confirm-dialog/ConfirmDialog.tsx +275 -0
  950. package/src/backend/confirm-dialog/index.ts +7 -0
  951. package/src/backend/confirm-dialog/useConfirmDialog.tsx +146 -0
  952. package/src/backend/custom-fields/FieldDefinitionsEditor.tsx +1413 -0
  953. package/src/backend/custom-fields/FieldDefinitionsManager.tsx +381 -0
  954. package/src/backend/dashboard/DashboardScreen.tsx +706 -0
  955. package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +114 -0
  956. package/src/backend/dashboard/index.ts +1 -0
  957. package/src/backend/dashboard/widgetRegistry.ts +68 -0
  958. package/src/backend/date-range/DateRangeSelect.tsx +50 -0
  959. package/src/backend/date-range/InlineDateRangeSelect.tsx +52 -0
  960. package/src/backend/date-range/InlineGranularitySelect.tsx +63 -0
  961. package/src/backend/date-range/__tests__/dateRanges.test.ts +224 -0
  962. package/src/backend/date-range/dateRanges.ts +218 -0
  963. package/src/backend/date-range/index.ts +15 -0
  964. package/src/backend/detail/AccessDeniedMessage.tsx +32 -0
  965. package/src/backend/detail/ActivitiesSection.tsx +1261 -0
  966. package/src/backend/detail/AddressEditor.tsx +479 -0
  967. package/src/backend/detail/AddressTiles.tsx +591 -0
  968. package/src/backend/detail/AddressesSection.tsx +362 -0
  969. package/src/backend/detail/AttachmentDeleteDialog.tsx +56 -0
  970. package/src/backend/detail/AttachmentMetadataDialog.tsx +674 -0
  971. package/src/backend/detail/AttachmentVisualPreview.tsx +155 -0
  972. package/src/backend/detail/AttachmentsSection.tsx +327 -0
  973. package/src/backend/detail/CustomDataSection.tsx +700 -0
  974. package/src/backend/detail/DetailFieldsSection.tsx +163 -0
  975. package/src/backend/detail/ErrorMessage.tsx +32 -0
  976. package/src/backend/detail/InlineEditors.tsx +894 -0
  977. package/src/backend/detail/LoadingMessage.tsx +14 -0
  978. package/src/backend/detail/NotesSection.tsx +1266 -0
  979. package/src/backend/detail/TabEmptyState.tsx +48 -0
  980. package/src/backend/detail/TagsSection.tsx +470 -0
  981. package/src/backend/detail/__tests__/InlineEditors.test.tsx +59 -0
  982. package/src/backend/detail/addressFormat.tsx +121 -0
  983. package/src/backend/detail/index.ts +46 -0
  984. package/src/backend/devtools/UmesDevToolsPanel.tsx +122 -0
  985. package/src/backend/devtools/components/ConflictWarnings.tsx +36 -0
  986. package/src/backend/devtools/components/EnricherTiming.tsx +50 -0
  987. package/src/backend/devtools/components/EventFlow.tsx +45 -0
  988. package/src/backend/devtools/components/ExtensionPointList.tsx +59 -0
  989. package/src/backend/devtools/components/InterceptorActivity.tsx +48 -0
  990. package/src/backend/devtools/index.ts +2 -0
  991. package/src/backend/devtools/useUmesDevTools.ts +188 -0
  992. package/src/backend/document-viewer/RegionOverlay.tsx +150 -0
  993. package/src/backend/document-viewer/index.ts +2 -0
  994. package/src/backend/dynamic-grid/DynamicGrid.tsx +161 -0
  995. package/src/backend/dynamic-grid/adapters.ts +57 -0
  996. package/src/backend/dynamic-grid/components/GridCell.tsx +194 -0
  997. package/src/backend/dynamic-grid/components/GridRow.tsx +40 -0
  998. package/src/backend/dynamic-grid/hooks/useGridKeyboardNav.ts +134 -0
  999. package/src/backend/dynamic-grid/index.ts +4 -0
  1000. package/src/backend/dynamic-grid/types.ts +55 -0
  1001. package/src/backend/dynamic-table/DynamicTable.stories.tsx +274 -0
  1002. package/src/backend/dynamic-table/DynamicTable.tsx +4419 -0
  1003. package/src/backend/dynamic-table/README.md +1199 -0
  1004. package/src/backend/dynamic-table/URL_FILTERING.md +217 -0
  1005. package/src/backend/dynamic-table/__e2e__/GridHarness.ts +1155 -0
  1006. package/src/backend/dynamic-table/__e2e__/SplitViewHarness.ts +366 -0
  1007. package/src/backend/dynamic-table/__tests__/BulkActionsBar.test.tsx +143 -0
  1008. package/src/backend/dynamic-table/__tests__/ColumnQuickFilter.test.tsx +607 -0
  1009. package/src/backend/dynamic-table/__tests__/ConfigureViewFilters.suggestions.test.tsx +177 -0
  1010. package/src/backend/dynamic-table/__tests__/ConfigureViewFormatting.test.tsx +125 -0
  1011. package/src/backend/dynamic-table/__tests__/ConfigureViewFormulas.test.tsx +228 -0
  1012. package/src/backend/dynamic-table/__tests__/ConfigureViewGrouping.aggregations.test.tsx +131 -0
  1013. package/src/backend/dynamic-table/__tests__/ConfigureViewPanel.baseView.test.tsx +162 -0
  1014. package/src/backend/dynamic-table/__tests__/ConfigureViewPanel.focus.test.tsx +54 -0
  1015. package/src/backend/dynamic-table/__tests__/ConfigureViewPanel.sections.test.tsx +142 -0
  1016. package/src/backend/dynamic-table/__tests__/ConfigureViewRollups.test.tsx +228 -0
  1017. package/src/backend/dynamic-table/__tests__/ContextMenu.test.tsx +89 -0
  1018. package/src/backend/dynamic-table/__tests__/DynamicTable.density.test.tsx +299 -0
  1019. package/src/backend/dynamic-table/__tests__/DynamicTable.exportTruncation.test.tsx +145 -0
  1020. package/src/backend/dynamic-table/__tests__/DynamicTable.loadError.test.tsx +173 -0
  1021. package/src/backend/dynamic-table/__tests__/DynamicTable.wiring.test.tsx +357 -0
  1022. package/src/backend/dynamic-table/__tests__/EmptyState.test.tsx +70 -0
  1023. package/src/backend/dynamic-table/__tests__/ExportMenu.test.tsx +82 -0
  1024. package/src/backend/dynamic-table/__tests__/FooterTotalsRow.test.tsx +154 -0
  1025. package/src/backend/dynamic-table/__tests__/GroupSummaryRow.render.test.tsx +131 -0
  1026. package/src/backend/dynamic-table/__tests__/LoadErrorState.test.tsx +74 -0
  1027. package/src/backend/dynamic-table/__tests__/MentionPopup.search.test.tsx +127 -0
  1028. package/src/backend/dynamic-table/__tests__/PerspectiveTabs.test.tsx +170 -0
  1029. package/src/backend/dynamic-table/__tests__/RowHeaderCell.selection.test.tsx +80 -0
  1030. package/src/backend/dynamic-table/__tests__/abbreviationsSubscription.test.ts +171 -0
  1031. package/src/backend/dynamic-table/__tests__/aggregate.spec.test.ts +224 -0
  1032. package/src/backend/dynamic-table/__tests__/aggregateDimension.test.tsx +191 -0
  1033. package/src/backend/dynamic-table/__tests__/anchoredPosition.test.ts +88 -0
  1034. package/src/backend/dynamic-table/__tests__/badgeAutoFit.test.ts +121 -0
  1035. package/src/backend/dynamic-table/__tests__/cellHandlers.save.test.ts +282 -0
  1036. package/src/backend/dynamic-table/__tests__/cellWrites.test.ts +274 -0
  1037. package/src/backend/dynamic-table/__tests__/clipboard.test.ts +230 -0
  1038. package/src/backend/dynamic-table/__tests__/coerceCellValue.test.ts +117 -0
  1039. package/src/backend/dynamic-table/__tests__/colorAdjacency.test.ts +151 -0
  1040. package/src/backend/dynamic-table/__tests__/columnFill.test.ts +66 -0
  1041. package/src/backend/dynamic-table/__tests__/columnHeaderHandlers.sort.test.ts +100 -0
  1042. package/src/backend/dynamic-table/__tests__/columnHeaderReorder.test.tsx +209 -0
  1043. package/src/backend/dynamic-table/__tests__/columnReorderPersistence.test.tsx +127 -0
  1044. package/src/backend/dynamic-table/__tests__/columnVirtualization.render.test.tsx +420 -0
  1045. package/src/backend/dynamic-table/__tests__/columnVisibilityPersistence.test.tsx +136 -0
  1046. package/src/backend/dynamic-table/__tests__/columnWindow.test.ts +643 -0
  1047. package/src/backend/dynamic-table/__tests__/commentAudience.test.tsx +137 -0
  1048. package/src/backend/dynamic-table/__tests__/commentScope.test.tsx +214 -0
  1049. package/src/backend/dynamic-table/__tests__/conditionalFormat.test.ts +175 -0
  1050. package/src/backend/dynamic-table/__tests__/contentRegistry.test.ts +267 -0
  1051. package/src/backend/dynamic-table/__tests__/dateWindows.test.ts +149 -0
  1052. package/src/backend/dynamic-table/__tests__/density.test.ts +204 -0
  1053. package/src/backend/dynamic-table/__tests__/exportTable.test.ts +146 -0
  1054. package/src/backend/dynamic-table/__tests__/fillGeometry.test.ts +201 -0
  1055. package/src/backend/dynamic-table/__tests__/fillPatterns.test.ts +272 -0
  1056. package/src/backend/dynamic-table/__tests__/filterParser.presets.test.ts +64 -0
  1057. package/src/backend/dynamic-table/__tests__/filterParser.relative.test.ts +109 -0
  1058. package/src/backend/dynamic-table/__tests__/filterSuggestions.test.ts +152 -0
  1059. package/src/backend/dynamic-table/__tests__/formatAggregate.test.ts +99 -0
  1060. package/src/backend/dynamic-table/__tests__/formatDateCompact.test.ts +489 -0
  1061. package/src/backend/dynamic-table/__tests__/formatLabelCompact.test.ts +717 -0
  1062. package/src/backend/dynamic-table/__tests__/formula.check.test.ts +257 -0
  1063. package/src/backend/dynamic-table/__tests__/formula.evaluate.test.ts +274 -0
  1064. package/src/backend/dynamic-table/__tests__/formula.functions.test.ts +226 -0
  1065. package/src/backend/dynamic-table/__tests__/formula.noEval.test.ts +92 -0
  1066. package/src/backend/dynamic-table/__tests__/formula.parse.test.ts +233 -0
  1067. package/src/backend/dynamic-table/__tests__/formulaColumns.test.tsx +213 -0
  1068. package/src/backend/dynamic-table/__tests__/handlerIdentity.test.ts +77 -0
  1069. package/src/backend/dynamic-table/__tests__/importMapping.test.ts +174 -0
  1070. package/src/backend/dynamic-table/__tests__/importParse.test.ts +130 -0
  1071. package/src/backend/dynamic-table/__tests__/importServerOnly.test.ts +37 -0
  1072. package/src/backend/dynamic-table/__tests__/layoutPersistence.test.ts +372 -0
  1073. package/src/backend/dynamic-table/__tests__/mouseHandlers.doubleClick.test.ts +155 -0
  1074. package/src/backend/dynamic-table/__tests__/perspectivePersonalization.test.ts +188 -0
  1075. package/src/backend/dynamic-table/__tests__/perspectiveResolver.test.ts +133 -0
  1076. package/src/backend/dynamic-table/__tests__/perspectiveState.defaultGrouping.test.ts +306 -0
  1077. package/src/backend/dynamic-table/__tests__/perspectiveState.defaults.test.ts +107 -0
  1078. package/src/backend/dynamic-table/__tests__/perspectiveTemplates.test.tsx +269 -0
  1079. package/src/backend/dynamic-table/__tests__/perspectiveTransforms.aggregations.test.ts +105 -0
  1080. package/src/backend/dynamic-table/__tests__/perspectiveTransforms.conditionalFormats.test.ts +107 -0
  1081. package/src/backend/dynamic-table/__tests__/perspectiveTransforms.dateFormat.test.ts +61 -0
  1082. package/src/backend/dynamic-table/__tests__/perspectiveTransforms.formulas.test.ts +85 -0
  1083. package/src/backend/dynamic-table/__tests__/perspectiveTransforms.frozenColumns.test.ts +124 -0
  1084. package/src/backend/dynamic-table/__tests__/rollupColumns.test.ts +149 -0
  1085. package/src/backend/dynamic-table/__tests__/sharedCriteria.test.ts +255 -0
  1086. package/src/backend/dynamic-table/__tests__/splitViewTree.test.ts +324 -0
  1087. package/src/backend/dynamic-table/__tests__/store.clipboardMarker.test.ts +143 -0
  1088. package/src/backend/dynamic-table/__tests__/store.fillPreview.test.ts +168 -0
  1089. package/src/backend/dynamic-table/__tests__/store.mutations.test.ts +345 -0
  1090. package/src/backend/dynamic-table/__tests__/store.selection.test.ts +157 -0
  1091. package/src/backend/dynamic-table/__tests__/store.undo.test.ts +225 -0
  1092. package/src/backend/dynamic-table/__tests__/store.valueSource.test.tsx +204 -0
  1093. package/src/backend/dynamic-table/__tests__/useAnnotations.legacyOptions.test.tsx +58 -0
  1094. package/src/backend/dynamic-table/__tests__/useColumnVirtualizer.test.ts +290 -0
  1095. package/src/backend/dynamic-table/__tests__/useDensityPreference.test.ts +435 -0
  1096. package/src/backend/dynamic-table/__tests__/useDynamicTablePage.duplicate.test.tsx +99 -0
  1097. package/src/backend/dynamic-table/__tests__/useDynamicTablePage.export.test.tsx +281 -0
  1098. package/src/backend/dynamic-table/__tests__/useDynamicTablePage.personalization.test.tsx +313 -0
  1099. package/src/backend/dynamic-table/__tests__/useDynamicTablePage.perspectiveRestore.test.tsx +126 -0
  1100. package/src/backend/dynamic-table/__tests__/useGrouping.aggregations.test.ts +158 -0
  1101. package/src/backend/dynamic-table/__tests__/useGrouping.groupKey.test.ts +58 -0
  1102. package/src/backend/dynamic-table/__tests__/useKeyboardNavigation.test.ts +594 -0
  1103. package/src/backend/dynamic-table/__tests__/useStickyOffsets.memo.test.tsx +75 -0
  1104. package/src/backend/dynamic-table/__tests__/useStickyOffsets.test.ts +106 -0
  1105. package/src/backend/dynamic-table/__tests__/widgetPane.test.tsx +338 -0
  1106. package/src/backend/dynamic-table/components/ActiveFilterChips.tsx +109 -0
  1107. package/src/backend/dynamic-table/components/BulkActionsBar.tsx +89 -0
  1108. package/src/backend/dynamic-table/components/Cell.tsx +292 -0
  1109. package/src/backend/dynamic-table/components/CellCommentDialog.tsx +887 -0
  1110. package/src/backend/dynamic-table/components/CellCommentHoverPopup.tsx +94 -0
  1111. package/src/backend/dynamic-table/components/ColumnFilterPopover.tsx +775 -0
  1112. package/src/backend/dynamic-table/components/ColumnHeaderMenu.tsx +189 -0
  1113. package/src/backend/dynamic-table/components/ColumnHeaders.tsx +867 -0
  1114. package/src/backend/dynamic-table/components/CompactLabelCell.tsx +293 -0
  1115. package/src/backend/dynamic-table/components/ConfigureViewFields.tsx +173 -0
  1116. package/src/backend/dynamic-table/components/ConfigureViewFilters.tsx +346 -0
  1117. package/src/backend/dynamic-table/components/ConfigureViewFormatting.tsx +205 -0
  1118. package/src/backend/dynamic-table/components/ConfigureViewFormulas.tsx +423 -0
  1119. package/src/backend/dynamic-table/components/ConfigureViewGrouping.tsx +264 -0
  1120. package/src/backend/dynamic-table/components/ConfigureViewLinkedColumns.tsx +249 -0
  1121. package/src/backend/dynamic-table/components/ConfigureViewPanel.tsx +857 -0
  1122. package/src/backend/dynamic-table/components/ConfigureViewRollups.tsx +364 -0
  1123. package/src/backend/dynamic-table/components/ConfigureViewSorting.tsx +121 -0
  1124. package/src/backend/dynamic-table/components/ContextMenu.tsx +140 -0
  1125. package/src/backend/dynamic-table/components/DateCell.tsx +205 -0
  1126. package/src/backend/dynamic-table/components/Debugger.tsx +340 -0
  1127. package/src/backend/dynamic-table/components/DensityControl.tsx +441 -0
  1128. package/src/backend/dynamic-table/components/EmptyState.tsx +103 -0
  1129. package/src/backend/dynamic-table/components/EntitySearchEditor.tsx +528 -0
  1130. package/src/backend/dynamic-table/components/ExportMenu.tsx +90 -0
  1131. package/src/backend/dynamic-table/components/FilterDatePicker.tsx +194 -0
  1132. package/src/backend/dynamic-table/components/FilterValueInput.tsx +294 -0
  1133. package/src/backend/dynamic-table/components/FooterTotalsRow.tsx +291 -0
  1134. package/src/backend/dynamic-table/components/FullscreenOverlay.tsx +66 -0
  1135. package/src/backend/dynamic-table/components/GroupHeaderRow.tsx +136 -0
  1136. package/src/backend/dynamic-table/components/GroupSummaryRow.tsx +184 -0
  1137. package/src/backend/dynamic-table/components/ImportPanel.tsx +445 -0
  1138. package/src/backend/dynamic-table/components/ImportPreviewGrid.tsx +155 -0
  1139. package/src/backend/dynamic-table/components/LoadErrorState.tsx +70 -0
  1140. package/src/backend/dynamic-table/components/MentionPopup.tsx +224 -0
  1141. package/src/backend/dynamic-table/components/PerspectiveDeleteDialog.tsx +96 -0
  1142. package/src/backend/dynamic-table/components/PerspectivePublishDialog.tsx +152 -0
  1143. package/src/backend/dynamic-table/components/PerspectiveTabMenu.tsx +215 -0
  1144. package/src/backend/dynamic-table/components/PerspectiveTabs.tsx +426 -0
  1145. package/src/backend/dynamic-table/components/PerspectiveTemplateMenu.tsx +135 -0
  1146. package/src/backend/dynamic-table/components/RowHeaderCell.tsx +73 -0
  1147. package/src/backend/dynamic-table/components/SearchBar.tsx +309 -0
  1148. package/src/backend/dynamic-table/components/SelectMenu.tsx +237 -0
  1149. package/src/backend/dynamic-table/components/StaleDataBar.tsx +74 -0
  1150. package/src/backend/dynamic-table/components/TableDeleteDialog.tsx +74 -0
  1151. package/src/backend/dynamic-table/components/TablePagination.tsx +96 -0
  1152. package/src/backend/dynamic-table/components/TableSkeleton.tsx +123 -0
  1153. package/src/backend/dynamic-table/components/ToolbarOverflow.tsx +284 -0
  1154. package/src/backend/dynamic-table/components/ViewModeSwitch.tsx +92 -0
  1155. package/src/backend/dynamic-table/components/VirtualRow.tsx +346 -0
  1156. package/src/backend/dynamic-table/components/editors.tsx +1727 -0
  1157. package/src/backend/dynamic-table/components/renderers.tsx +162 -0
  1158. package/src/backend/dynamic-table/events/events.ts +107 -0
  1159. package/src/backend/dynamic-table/formula/check.ts +359 -0
  1160. package/src/backend/dynamic-table/formula/evaluate.ts +473 -0
  1161. package/src/backend/dynamic-table/formula/functions.ts +737 -0
  1162. package/src/backend/dynamic-table/formula/index.ts +57 -0
  1163. package/src/backend/dynamic-table/formula/parse.ts +414 -0
  1164. package/src/backend/dynamic-table/formula/types.ts +151 -0
  1165. package/src/backend/dynamic-table/handlers/cellWrites.ts +362 -0
  1166. package/src/backend/dynamic-table/handlers/index.ts +789 -0
  1167. package/src/backend/dynamic-table/handlers/perspectiveHandlers.ts +684 -0
  1168. package/src/backend/dynamic-table/hooks/index.ts +888 -0
  1169. package/src/backend/dynamic-table/hooks/useAbbreviations.ts +67 -0
  1170. package/src/backend/dynamic-table/hooks/useAnnotations.ts +215 -0
  1171. package/src/backend/dynamic-table/hooks/useColumnVirtualizer.ts +303 -0
  1172. package/src/backend/dynamic-table/hooks/useColumnWidthPersistence.ts +214 -0
  1173. package/src/backend/dynamic-table/hooks/useDensityPreference.ts +451 -0
  1174. package/src/backend/dynamic-table/hooks/useDynamicTablePage.tsx +1681 -0
  1175. package/src/backend/dynamic-table/hooks/useEscapeLayer.ts +72 -0
  1176. package/src/backend/dynamic-table/hooks/useGrouping.ts +428 -0
  1177. package/src/backend/dynamic-table/hooks/useSuggestionFetch.ts +109 -0
  1178. package/src/backend/dynamic-table/hooks/useTableDateFormat.tsx +19 -0
  1179. package/src/backend/dynamic-table/index.ts +497 -0
  1180. package/src/backend/dynamic-table/registry/ContentRegistryContext.tsx +244 -0
  1181. package/src/backend/dynamic-table/registry/TableRegistryContext.tsx +84 -0
  1182. package/src/backend/dynamic-table/registry/filterAccessibleTables.ts +36 -0
  1183. package/src/backend/dynamic-table/registry/index.ts +38 -0
  1184. package/src/backend/dynamic-table/registry/lazyTable.tsx +80 -0
  1185. package/src/backend/dynamic-table/registry/types.ts +181 -0
  1186. package/src/backend/dynamic-table/registry/widgetCatalog.ts +226 -0
  1187. package/src/backend/dynamic-table/server/aggregate.ts +474 -0
  1188. package/src/backend/dynamic-table/server/dateWindows.ts +201 -0
  1189. package/src/backend/dynamic-table/server/filterParser.ts +132 -0
  1190. package/src/backend/dynamic-table/server/filterSuggestions.ts +150 -0
  1191. package/src/backend/dynamic-table/server/index.ts +47 -0
  1192. package/src/backend/dynamic-table/server/makeDynamicTableRoute.ts +426 -0
  1193. package/src/backend/dynamic-table/split-view/ContentPicker.tsx +251 -0
  1194. package/src/backend/dynamic-table/split-view/EmptySlot.tsx +69 -0
  1195. package/src/backend/dynamic-table/split-view/SplitViewHost.tsx +1336 -0
  1196. package/src/backend/dynamic-table/split-view/WidgetPane.tsx +390 -0
  1197. package/src/backend/dynamic-table/split-view/WorkspaceFilterBar.tsx +728 -0
  1198. package/src/backend/dynamic-table/split-view/index.ts +48 -0
  1199. package/src/backend/dynamic-table/split-view/layoutPersistence.ts +185 -0
  1200. package/src/backend/dynamic-table/split-view/sharedCriteria.ts +196 -0
  1201. package/src/backend/dynamic-table/split-view/types.ts +778 -0
  1202. package/src/backend/dynamic-table/split-view/useSplitViewLayouts.ts +97 -0
  1203. package/src/backend/dynamic-table/store/index.ts +1011 -0
  1204. package/src/backend/dynamic-table/styles/ContextMenu.css +387 -0
  1205. package/src/backend/dynamic-table/styles/DynamicTable.css +3659 -0
  1206. package/src/backend/dynamic-table/styles/DynamicTable.v2.css +2651 -0
  1207. package/src/backend/dynamic-table/styles/SearchBar.css +426 -0
  1208. package/src/backend/dynamic-table/styles/dateCell.css +146 -0
  1209. package/src/backend/dynamic-table/styles/density.css +690 -0
  1210. package/src/backend/dynamic-table/types/density.ts +179 -0
  1211. package/src/backend/dynamic-table/types/filters.ts +156 -0
  1212. package/src/backend/dynamic-table/types/grouping.ts +189 -0
  1213. package/src/backend/dynamic-table/types/import.ts +132 -0
  1214. package/src/backend/dynamic-table/types/index.ts +1198 -0
  1215. package/src/backend/dynamic-table/types/perspective.ts +436 -0
  1216. package/src/backend/dynamic-table/types/rollup.ts +111 -0
  1217. package/src/backend/dynamic-table/utils/abbreviations.ts +556 -0
  1218. package/src/backend/dynamic-table/utils/anchoredPosition.ts +106 -0
  1219. package/src/backend/dynamic-table/utils/badgeAutoFit.ts +108 -0
  1220. package/src/backend/dynamic-table/utils/clipboard.ts +294 -0
  1221. package/src/backend/dynamic-table/utils/coerceCellValue.ts +96 -0
  1222. package/src/backend/dynamic-table/utils/colorAdjacency.ts +87 -0
  1223. package/src/backend/dynamic-table/utils/columnFill.ts +93 -0
  1224. package/src/backend/dynamic-table/utils/columnSpacerStyle.ts +53 -0
  1225. package/src/backend/dynamic-table/utils/columnWindow.ts +668 -0
  1226. package/src/backend/dynamic-table/utils/conditionalFormat.ts +300 -0
  1227. package/src/backend/dynamic-table/utils/exportTable.ts +140 -0
  1228. package/src/backend/dynamic-table/utils/fillGeometry.ts +187 -0
  1229. package/src/backend/dynamic-table/utils/fillPatterns.ts +422 -0
  1230. package/src/backend/dynamic-table/utils/formatAggregate.ts +145 -0
  1231. package/src/backend/dynamic-table/utils/formatDateCompact.ts +879 -0
  1232. package/src/backend/dynamic-table/utils/formatLabelCompact.ts +640 -0
  1233. package/src/backend/dynamic-table/utils/formulaColumns.tsx +237 -0
  1234. package/src/backend/dynamic-table/utils/importMapping.ts +202 -0
  1235. package/src/backend/dynamic-table/utils/importParse.ts +220 -0
  1236. package/src/backend/dynamic-table/utils/lookupColumns.tsx +47 -0
  1237. package/src/backend/dynamic-table/utils/perspectiveTransforms.ts +368 -0
  1238. package/src/backend/dynamic-table/utils/rollupColumns.ts +162 -0
  1239. package/src/backend/dynamic-table/validators/index.ts +40 -0
  1240. package/src/backend/fields/registry.generated.ts +8 -0
  1241. package/src/backend/fields/registry.ts +38 -0
  1242. package/src/backend/filters/AdvancedFilterBuilder.tsx +339 -0
  1243. package/src/backend/forms/ActionsDropdown.tsx +198 -0
  1244. package/src/backend/forms/FormActionButtons.tsx +98 -0
  1245. package/src/backend/forms/FormFooter.tsx +27 -0
  1246. package/src/backend/forms/FormHeader.tsx +204 -0
  1247. package/src/backend/forms/index.ts +4 -0
  1248. package/src/backend/hooks/useAdvancedFilter.ts +77 -0
  1249. package/src/backend/indexes/PartialIndexBanner.tsx +86 -0
  1250. package/src/backend/indexes/store.ts +88 -0
  1251. package/src/backend/injection/ComponentOverrideProvider.tsx +66 -0
  1252. package/src/backend/injection/InjectedField.tsx +196 -0
  1253. package/src/backend/injection/InjectionSpot.tsx +457 -0
  1254. package/src/backend/injection/InjectionWizard.tsx +335 -0
  1255. package/src/backend/injection/PageInjectionBoundary.tsx +31 -0
  1256. package/src/backend/injection/SseEventIndicator.tsx +11 -0
  1257. package/src/backend/injection/StatusBadgeInjectionSpot.tsx +50 -0
  1258. package/src/backend/injection/StatusBadgeRenderer.tsx +100 -0
  1259. package/src/backend/injection/WidgetSharedState.ts +58 -0
  1260. package/src/backend/injection/WizardInjectionSpot.tsx +46 -0
  1261. package/src/backend/injection/eventBridge.ts +158 -0
  1262. package/src/backend/injection/helpers.ts +35 -0
  1263. package/src/backend/injection/mergeMenuItems.ts +80 -0
  1264. package/src/backend/injection/mutationEvents.ts +22 -0
  1265. package/src/backend/injection/resolveInjectedIcon.tsx +30 -0
  1266. package/src/backend/injection/spotIds.ts +64 -0
  1267. package/src/backend/injection/useAppEvent.ts +76 -0
  1268. package/src/backend/injection/useGuardedMutation.ts +101 -0
  1269. package/src/backend/injection/useInjectedMenuItems.ts +67 -0
  1270. package/src/backend/injection/useInjectionDataWidgets.ts +41 -0
  1271. package/src/backend/injection/useOperationProgress.ts +105 -0
  1272. package/src/backend/injection/useRegisteredComponent.tsx +106 -0
  1273. package/src/backend/injection/useWidgetSharedState.ts +28 -0
  1274. package/src/backend/injection/visibility-utils.ts +31 -0
  1275. package/src/backend/injection/widgetRegistry.ts +68 -0
  1276. package/src/backend/inputs/ComboboxInput.tsx +272 -0
  1277. package/src/backend/inputs/DatePicker.tsx +162 -0
  1278. package/src/backend/inputs/DateTimePicker.tsx +199 -0
  1279. package/src/backend/inputs/EventPatternInput.tsx +46 -0
  1280. package/src/backend/inputs/EventSelect.tsx +183 -0
  1281. package/src/backend/inputs/LookupSelect.tsx +253 -0
  1282. package/src/backend/inputs/PhoneNumberField.tsx +166 -0
  1283. package/src/backend/inputs/SwitchableMarkdownInput.tsx +106 -0
  1284. package/src/backend/inputs/TagsInput.tsx +297 -0
  1285. package/src/backend/inputs/TimeInput.tsx +146 -0
  1286. package/src/backend/inputs/TimePicker.tsx +120 -0
  1287. package/src/backend/inputs/__tests__/DatePicker.test.tsx +76 -0
  1288. package/src/backend/inputs/__tests__/DateTimePicker.logic.test.ts +126 -0
  1289. package/src/backend/inputs/__tests__/PhoneNumberField.test.tsx +73 -0
  1290. package/src/backend/inputs/__tests__/TagsInput.test.tsx +78 -0
  1291. package/src/backend/inputs/__tests__/TimeInput.test.tsx +134 -0
  1292. package/src/backend/inputs/__tests__/TimePicker.test.tsx +50 -0
  1293. package/src/backend/inputs/index.ts +11 -0
  1294. package/src/backend/markdown/MarkdownContent.tsx +33 -0
  1295. package/src/backend/markdown/index.ts +2 -0
  1296. package/src/backend/markdown/useMarkdownRemarkPlugins.ts +39 -0
  1297. package/src/backend/messages/MessageComposer.tsx +197 -0
  1298. package/src/backend/messages/MessageObjectDetail.tsx +95 -0
  1299. package/src/backend/messages/MessageObjectPreview.tsx +64 -0
  1300. package/src/backend/messages/MessageObjectRecordPicker.tsx +149 -0
  1301. package/src/backend/messages/MessagePrioritySelector.tsx +78 -0
  1302. package/src/backend/messages/MessagesIcon.tsx +32 -0
  1303. package/src/backend/messages/SendObjectMessageDialog.tsx +74 -0
  1304. package/src/backend/messages/__tests__/MessageComposer.test.tsx +350 -0
  1305. package/src/backend/messages/__tests__/SendObjectMessageDialog.test.tsx +77 -0
  1306. package/src/backend/messages/index.ts +22 -0
  1307. package/src/backend/messages/message-compose-form-groups.tsx +383 -0
  1308. package/src/backend/messages/message-composer.types.ts +82 -0
  1309. package/src/backend/messages/message-priority.ts +53 -0
  1310. package/src/backend/messages/useMessageCompose.ts +666 -0
  1311. package/src/backend/messages/useMessageComposeOperations.ts +259 -0
  1312. package/src/backend/messages/useMessagesPoll.ts +118 -0
  1313. package/src/backend/notifications/NotificationBell.tsx +91 -0
  1314. package/src/backend/notifications/NotificationCountBadge.tsx +14 -0
  1315. package/src/backend/notifications/NotificationDispatcher.ts +213 -0
  1316. package/src/backend/notifications/NotificationItem.tsx +262 -0
  1317. package/src/backend/notifications/NotificationPanel.tsx +225 -0
  1318. package/src/backend/notifications/__tests__/NotificationCountBadge.test.tsx +34 -0
  1319. package/src/backend/notifications/index.ts +20 -0
  1320. package/src/backend/notifications/useNotificationActions.ts +161 -0
  1321. package/src/backend/notifications/useNotificationEffect.ts +17 -0
  1322. package/src/backend/notifications/useNotifications.ts +12 -0
  1323. package/src/backend/notifications/useNotificationsPoll.ts +167 -0
  1324. package/src/backend/notifications/useNotificationsSse.ts +220 -0
  1325. package/src/backend/operations/LastOperationBanner.tsx +85 -0
  1326. package/src/backend/operations/__tests__/LastOperationBanner.test.tsx +101 -0
  1327. package/src/backend/operations/store.ts +230 -0
  1328. package/src/backend/progress/ProgressTopBar.tsx +189 -0
  1329. package/src/backend/progress/useProgress.ts +12 -0
  1330. package/src/backend/progress/useProgressPoll.ts +100 -0
  1331. package/src/backend/progress/useProgressSse.ts +118 -0
  1332. package/src/backend/schedule/ScheduleAgenda.tsx +136 -0
  1333. package/src/backend/schedule/ScheduleGrid.tsx +136 -0
  1334. package/src/backend/schedule/ScheduleToolbar.tsx +178 -0
  1335. package/src/backend/schedule/ScheduleView.tsx +201 -0
  1336. package/src/backend/schedule/index.ts +5 -0
  1337. package/src/backend/schedule/recurrence.ts +99 -0
  1338. package/src/backend/schedule/types.ts +26 -0
  1339. package/src/backend/section-page/SectionNav.tsx +140 -0
  1340. package/src/backend/section-page/SectionPage.tsx +34 -0
  1341. package/src/backend/section-page/index.ts +3 -0
  1342. package/src/backend/section-page/types.ts +30 -0
  1343. package/src/backend/settings/SettingsNavigation.tsx +74 -0
  1344. package/src/backend/settings/SettingsPageWrapper.tsx +78 -0
  1345. package/src/backend/settings/index.ts +3 -0
  1346. package/src/backend/upgrades/UpgradeActionBanner.tsx +128 -0
  1347. package/src/backend/utils/__tests__/api.scoped-headers.test.ts +58 -0
  1348. package/src/backend/utils/__tests__/api.test.ts +148 -0
  1349. package/src/backend/utils/__tests__/apiCall.scoped-request-headers.test.ts +58 -0
  1350. package/src/backend/utils/__tests__/apiCall.test.ts +109 -0
  1351. package/src/backend/utils/__tests__/crud.test.ts +87 -0
  1352. package/src/backend/utils/__tests__/customFieldDefs.test.ts +25 -0
  1353. package/src/backend/utils/__tests__/customFieldValues.test.ts +35 -0
  1354. package/src/backend/utils/__tests__/richTextSanitizer.test.ts +57 -0
  1355. package/src/backend/utils/api.ts +214 -0
  1356. package/src/backend/utils/apiCall.ts +123 -0
  1357. package/src/backend/utils/crud.ts +174 -0
  1358. package/src/backend/utils/customFieldColumns.ts +118 -0
  1359. package/src/backend/utils/customFieldDefs.ts +247 -0
  1360. package/src/backend/utils/customFieldFilters.ts +174 -0
  1361. package/src/backend/utils/customFieldForms.ts +203 -0
  1362. package/src/backend/utils/customFieldRelationDisplay.ts +302 -0
  1363. package/src/backend/utils/customFieldValues.ts +41 -0
  1364. package/src/backend/utils/flash.ts +17 -0
  1365. package/src/backend/utils/nav.ts +402 -0
  1366. package/src/backend/utils/richTextSanitizer.ts +7 -0
  1367. package/src/backend/utils/scopedHeaderStack.ts +49 -0
  1368. package/src/backend/utils/serverErrors.ts +313 -0
  1369. package/src/backend/utils/useAutoDiscoveredFields.ts +138 -0
  1370. package/src/backend/version-history/VersionHistoryAction.tsx +76 -0
  1371. package/src/backend/version-history/VersionHistoryDetail.tsx +87 -0
  1372. package/src/backend/version-history/VersionHistoryPanel.tsx +331 -0
  1373. package/src/backend/version-history/index.ts +9 -0
  1374. package/src/backend/version-history/labels.ts +56 -0
  1375. package/src/backend/version-history/types.ts +27 -0
  1376. package/src/backend/version-history/useAuditPermissions.ts +94 -0
  1377. package/src/backend/version-history/useVersionHistory.ts +173 -0
  1378. package/src/backend/views/NewViewForm.tsx +54 -0
  1379. package/src/backend/views/ShareForm.tsx +62 -0
  1380. package/src/backend/views/ViewChip.tsx +123 -0
  1381. package/src/backend/views/ViewChipMenu.tsx +94 -0
  1382. package/src/backend/views/types.ts +4 -0
  1383. package/src/frontend/AuthFooter.tsx +29 -0
  1384. package/src/frontend/LanguageSwitcher.tsx +66 -0
  1385. package/src/frontend/Layout.tsx +13 -0
  1386. package/src/hooks/useIsMobile.ts +27 -0
  1387. package/src/index.ts +40 -0
  1388. package/src/portal/PortalContext.tsx +180 -0
  1389. package/src/portal/PortalLayoutProvider.tsx +5 -0
  1390. package/src/portal/PortalLayoutShell.tsx +64 -0
  1391. package/src/portal/PortalShell.tsx +370 -0
  1392. package/src/portal/components/PortalCard.tsx +77 -0
  1393. package/src/portal/components/PortalEmptyState.tsx +32 -0
  1394. package/src/portal/components/PortalFeatureCard.tsx +41 -0
  1395. package/src/portal/components/PortalNotificationBell.tsx +81 -0
  1396. package/src/portal/components/PortalNotificationPanel.tsx +230 -0
  1397. package/src/portal/components/PortalPageHeader.tsx +33 -0
  1398. package/src/portal/components/index.ts +6 -0
  1399. package/src/portal/hooks/useCustomerAuth.ts +97 -0
  1400. package/src/portal/hooks/usePortalAppEvent.ts +56 -0
  1401. package/src/portal/hooks/usePortalDashboardWidgets.ts +50 -0
  1402. package/src/portal/hooks/usePortalEventBridge.ts +162 -0
  1403. package/src/portal/hooks/usePortalInjectedMenuItems.ts +130 -0
  1404. package/src/portal/hooks/usePortalNotifications.ts +116 -0
  1405. package/src/portal/hooks/useTenantContext.ts +83 -0
  1406. package/src/primitives/DataLoader.tsx +93 -0
  1407. package/src/primitives/ErrorNotice.tsx +17 -0
  1408. package/src/primitives/Notice.tsx +83 -0
  1409. package/src/primitives/alert.tsx +59 -0
  1410. package/src/primitives/badge.tsx +43 -0
  1411. package/src/primitives/button.tsx +60 -0
  1412. package/src/primitives/calendar.tsx +69 -0
  1413. package/src/primitives/card.tsx +92 -0
  1414. package/src/primitives/checkbox.tsx +34 -0
  1415. package/src/primitives/dialog.tsx +122 -0
  1416. package/src/primitives/form-field.tsx +106 -0
  1417. package/src/primitives/icon-button.tsx +48 -0
  1418. package/src/primitives/input.tsx +26 -0
  1419. package/src/primitives/label.tsx +18 -0
  1420. package/src/primitives/popover.tsx +37 -0
  1421. package/src/primitives/primitives.stories.tsx +223 -0
  1422. package/src/primitives/progress.tsx +28 -0
  1423. package/src/primitives/separator.tsx +7 -0
  1424. package/src/primitives/sheet.tsx +137 -0
  1425. package/src/primitives/spinner.tsx +27 -0
  1426. package/src/primitives/status-badge.tsx +62 -0
  1427. package/src/primitives/switch.tsx +92 -0
  1428. package/src/primitives/table.tsx +27 -0
  1429. package/src/primitives/tabs.tsx +131 -0
  1430. package/src/primitives/textarea.tsx +20 -0
  1431. package/src/primitives/tooltip.tsx +85 -0
  1432. package/src/primitives-v2/AGENTS.md +66 -0
  1433. package/src/primitives-v2/Alert.stories.tsx +56 -0
  1434. package/src/primitives-v2/Alert.tsx +108 -0
  1435. package/src/primitives-v2/Avatar.stories.tsx +60 -0
  1436. package/src/primitives-v2/Avatar.tsx +139 -0
  1437. package/src/primitives-v2/Badge.stories.tsx +80 -0
  1438. package/src/primitives-v2/Badge.tsx +99 -0
  1439. package/src/primitives-v2/Breadcrumb.stories.tsx +61 -0
  1440. package/src/primitives-v2/Breadcrumb.tsx +96 -0
  1441. package/src/primitives-v2/Button.stories.tsx +72 -0
  1442. package/src/primitives-v2/Button.tsx +178 -0
  1443. package/src/primitives-v2/Checkbox.stories.tsx +34 -0
  1444. package/src/primitives-v2/Checkbox.tsx +103 -0
  1445. package/src/primitives-v2/IconButton.stories.tsx +40 -0
  1446. package/src/primitives-v2/IconButton.tsx +40 -0
  1447. package/src/primitives-v2/Input.stories.tsx +60 -0
  1448. package/src/primitives-v2/Input.tsx +113 -0
  1449. package/src/primitives-v2/NavGroup.stories.tsx +50 -0
  1450. package/src/primitives-v2/NavGroup.tsx +76 -0
  1451. package/src/primitives-v2/NavItem.stories.tsx +68 -0
  1452. package/src/primitives-v2/NavItem.tsx +105 -0
  1453. package/src/primitives-v2/SearchInput.stories.tsx +44 -0
  1454. package/src/primitives-v2/SearchInput.tsx +123 -0
  1455. package/src/primitives-v2/Sidebar.stories.tsx +91 -0
  1456. package/src/primitives-v2/Sidebar.tsx +92 -0
  1457. package/src/primitives-v2/Tab.stories.tsx +62 -0
  1458. package/src/primitives-v2/Tab.tsx +148 -0
  1459. package/src/primitives-v2/Tag.stories.tsx +123 -0
  1460. package/src/primitives-v2/Tag.tsx +115 -0
  1461. package/src/primitives-v2/Toggle.stories.tsx +37 -0
  1462. package/src/primitives-v2/Toggle.tsx +93 -0
  1463. package/src/primitives-v2/Topbar.stories.tsx +65 -0
  1464. package/src/primitives-v2/Topbar.tsx +37 -0
  1465. package/src/primitives-v2/index.ts +31 -0
  1466. package/src/primitives-v2/utils.ts +9 -0
  1467. package/src/theme/BrandThemeProvider.tsx +122 -0
  1468. package/src/theme/QueryProvider.tsx +46 -0
  1469. package/src/theme/ThemeProvider.tsx +120 -0
  1470. package/src/theme/ThemeToggle.tsx +88 -0
  1471. package/src/theme/index.ts +4 -0
  1472. package/src/theme/m3.css +622 -0
  1473. package/src/theme/tokens.v2.css +559 -0
  1474. package/src/theme/typography.css +163 -0
  1475. package/src/theme/typography.stories.tsx +157 -0
  1476. package/src/theme/typography.tokens.ts +326 -0
  1477. package/src/types/react-big-calendar.d.ts +16 -0
@@ -0,0 +1,4419 @@
1
+ // DynamicTable.tsx
2
+
3
+
4
+ 'use client';
5
+
6
+ import React, {
7
+ useState,
8
+ useEffect,
9
+ useLayoutEffect,
10
+ useRef,
11
+ useMemo,
12
+ useCallback,
13
+ } from 'react';
14
+ import { useVirtualizer } from '@tanstack/react-virtual';
15
+
16
+ import { createCellStore, CellStore } from './store/index';
17
+ import {
18
+ CellStoreContext,
19
+ useStickyOffsets,
20
+ ROW_HEADER_WIDTH,
21
+ useKeyboardNavigation,
22
+ useCopyHandler,
23
+ useRowActionShortcuts,
24
+ columnMountsEditor,
25
+ } from './hooks/index';
26
+ import {
27
+ createCellHandlers,
28
+ createRowHandlers,
29
+ createDragHandlers,
30
+ createMouseHandlers,
31
+ createColumnHeaderHandlers,
32
+ createRowHeaderHandlers,
33
+ createContextMenuHandlers,
34
+ createResizeHandlers,
35
+ DragState,
36
+ } from './handlers/index';
37
+ import { createPerspectiveHandlers, resolvePerspectiveState } from './handlers/perspectiveHandlers';
38
+ import { TableDateFormatContext } from './hooks/useTableDateFormat';
39
+ import { useColumnWidthPersistence } from './hooks/useColumnWidthPersistence';
40
+ import { useColumnVirtualizer } from './hooks/useColumnVirtualizer';
41
+ import { getPinnedColumnIndices } from './utils/columnWindow';
42
+ import { computeColumnFill } from './utils/columnFill';
43
+ import { useDensityPreference } from './hooks/useDensityPreference';
44
+ import { DENSITY_ATTRIBUTE, resolveDensityRowHeight } from './types/density';
45
+ import { DensityControl } from './components/DensityControl';
46
+ import { ToolbarOverflow } from './components/ToolbarOverflow';
47
+ import { dispatch, useEventHandlers } from './events/events';
48
+ import {
49
+ ColumnDef,
50
+ ContextMenuState,
51
+ SortState,
52
+ FilterRow,
53
+ TableEvents,
54
+ FilterChangeEvent,
55
+ PaginationProps,
56
+ ContextMenuAction,
57
+ SavedFilter,
58
+ TableUIConfig,
59
+ LoadFilterSuggestions,
60
+ LoadLookupSources,
61
+ KeyboardShortcutsConfig,
62
+ OnRowAction,
63
+ SelectionBounds,
64
+ } from './types/index';
65
+ import {
66
+ PerspectiveConfig,
67
+ PerspectiveTemplate,
68
+ SortRule,
69
+ PerspectiveChangeEvent,
70
+ LookupColumnRef,
71
+ } from './types/perspective';
72
+ import type { TableViewMode } from './types/perspective';
73
+ import type { TableBodyRenderContext, ExportAllResult } from './types/index';
74
+ import { ViewModeSwitch } from './components/ViewModeSwitch';
75
+ import { buildLookupColumnDefs } from './utils/lookupColumns';
76
+ import type { LoadRollupSources, RollupColumnRef } from './types/rollup';
77
+ import { buildRollupColumnDefs } from './utils/rollupColumns';
78
+ import { applyCellWrites } from './handlers/cellWrites';
79
+ import type { CellWrite, CellWriteOrigin, CellWriteReport } from './handlers/cellWrites';
80
+ import { coerceCellValue } from './utils/coerceCellValue';
81
+ import { parseClipboardGrid, selectionToTsv } from './utils/clipboard';
82
+ import { detectFillPattern, generateFill, invertFillMode } from './utils/fillPatterns';
83
+ import type { FillMode, FillSeed } from './utils/fillPatterns';
84
+ import { buildFillLines, cellsInRect, computeFillPreview } from './utils/fillGeometry';
85
+ import { compileConditionalFormats } from './utils/conditionalFormat';
86
+ import type { ConditionalFormatRule } from './utils/conditionalFormat';
87
+ import { buildFormulaColumnDefs, isFormulaColumnKey } from './utils/formulaColumns';
88
+ import { fieldMetaFromColumns } from './formula/check';
89
+ import type { FormulaColumnRef } from './formula/types';
90
+ import { badgeFitWidth, recordBadgeFit, planBadgeWidths } from './utils/badgeAutoFit';
91
+ import type { GroupRule, AggregationRule, AggregateResult, VisualRow as VisualRowType } from './types/grouping';
92
+
93
+ import { useGrouping, computeAggregates } from './hooks/useGrouping';
94
+
95
+ // Import components
96
+ import PerspectiveTabs from './components/PerspectiveTabs';
97
+ import ConfigureViewPanel from './components/ConfigureViewPanel';
98
+ import SearchBar from './components/SearchBar';
99
+ import ActiveFilterChips from './components/ActiveFilterChips';
100
+ import EmptyState from './components/EmptyState';
101
+ import LoadErrorState from './components/LoadErrorState';
102
+ import StaleDataBar from './components/StaleDataBar';
103
+ import ContextMenu from './components/ContextMenu';
104
+ import VirtualRow from './components/VirtualRow';
105
+ import GroupHeaderRow from './components/GroupHeaderRow';
106
+ import GroupSummaryRow from './components/GroupSummaryRow';
107
+ import FooterTotalsRow from './components/FooterTotalsRow';
108
+ import ColumnHeaders from './components/ColumnHeaders';
109
+ import BulkActionsBar from './components/BulkActionsBar';
110
+ import ExportMenu from './components/ExportMenu';
111
+ import {
112
+ extractExportRows,
113
+ extractExportRowsFromData,
114
+ toCsv,
115
+ toXlsxBlob,
116
+ slugifyFileName,
117
+ downloadBlob,
118
+ type ExtractedTable,
119
+ type ExportFormat,
120
+ } from './utils/exportTable';
121
+ import Debugger from './components/Debugger';
122
+ import FullscreenOverlay from './components/FullscreenOverlay';
123
+ import { Maximize2, FunnelX, Replace } from 'lucide-react';
124
+ import { generateSortRuleId, generatePerspectiveId, BASE_VIEW_PERSPECTIVE_NAME } from './types/perspective';
125
+ import { useAnnotations, annotationKey } from './hooks/useAnnotations';
126
+ import type { AnnotationComment } from './hooks/useAnnotations';
127
+ import CellCommentDialog from './components/CellCommentDialog';
128
+ import CellCommentHoverPopup from './components/CellCommentHoverPopup';
129
+ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../primitives/dialog';
130
+ import { Button } from '../../primitives/button';
131
+ import { useT, useLocale } from '@open-mercato/shared/lib/i18n/context';
132
+ import { flash } from '../FlashMessages';
133
+ import TablePagination from './components/TablePagination';
134
+
135
+ /**
136
+ * Stable empty default for `defaultGrouping`. A fresh `[]` per render would give
137
+ * every effect that depends on it a new identity each commit — and one of those
138
+ * effects RESETS the table's group rules, so an inline default would silently
139
+ * undo the user's own grouping on the next render.
140
+ */
141
+ const EMPTY_GROUP_RULES: GroupRule[] = [];
142
+
143
+ // Built-in action ids for the drag-selection context menu (handled internally
144
+ // in DynamicTable, not dispatched to consumer cellActions handlers).
145
+ const SELECTION_COPY_ID = '__cellsel_copy';
146
+ const SELECTION_ANNOTATE_ID = '__cellsel_annotate';
147
+
148
+ /** Input types that are buttons/toggles, not places a caret can sit. */
149
+ const NON_TEXT_INPUT_TYPES = ['checkbox', 'radio', 'button', 'submit', 'range', 'color', 'file'];
150
+
151
+ /**
152
+ * Is this element a place the user is typing into?
153
+ *
154
+ * Every grid-level keyboard shortcut that shadows a native text-editing key
155
+ * (`Ctrl+Z`, `Ctrl+D`, `Delete`, `Backspace`) has to defer to the search box,
156
+ * a filter input or an open cell editor. One predicate so a new shortcut
157
+ * cannot get the exemption list subtly wrong.
158
+ */
159
+ function isTextEntryTarget(el: HTMLElement | null | undefined): boolean {
160
+ if (!el) return false;
161
+ const tag = el.tagName;
162
+ if (tag === 'TEXTAREA' || el.isContentEditable) return true;
163
+ return tag === 'INPUT' && !NON_TEXT_INPUT_TYPES.includes((el as HTMLInputElement).type);
164
+ }
165
+
166
+ if (typeof window !== 'undefined') {
167
+ // @ts-ignore - CSS import handled by bundler
168
+ import('./styles/DynamicTable.css');
169
+ // @ts-ignore - v2 appearance theme layer (scoped under .hot-appearance-v2)
170
+ import('./styles/DynamicTable.v2.css');
171
+ }
172
+
173
+ // ============================================
174
+ // PROPS INTERFACE
175
+ // ============================================
176
+
177
+ export interface DynamicTableProps {
178
+ data?: any[];
179
+ columns?: ColumnDef[];
180
+ colHeaders?: boolean;
181
+ rowHeaders?: boolean;
182
+ height?: string | number;
183
+ width?: string | number;
184
+ idColumnName?: string;
185
+ /**
186
+ * Column holding a value that is **unique per visual row**, used as the
187
+ * identity key for checkbox selection. Defaults to `idColumnName`. Set this
188
+ * when `idColumnName` is an entity key that repeats across rows (e.g. a
189
+ * transport grid with one row per leg but `idColumnName='unitId'`), otherwise
190
+ * selecting one row also selects its siblings sharing the same id.
191
+ */
192
+ rowKeyColumn?: string;
193
+ tableName?: string;
194
+ /**
195
+ * The LIST request failed (HEDGE-119) — as opposed to "the list is empty".
196
+ * Both reach this component as `data: []`, so without this flag the zero-row
197
+ * region cannot tell them apart and renders `EmptyState` for a 500. Defaults
198
+ * false; every pre-existing host keeps its current behaviour.
199
+ */
200
+ /**
201
+ * REQUIRED, not optional — HEDGE-123.
202
+ *
203
+ * It was `loadError?: boolean` and defaulted to `false`, which quietly meant
204
+ * "every caller that has not thought about this asserts the fetch succeeded".
205
+ * The hook-driven tables were fixed; six components that fetch their own data
206
+ * and render this component directly were simply never visited, and went on
207
+ * reporting a 500 as "no rows" — a contractor with a failed address request
208
+ * looked like a contractor with no addresses.
209
+ *
210
+ * Optionality is what let that happen silently. Required, the compiler names
211
+ * every site that renders a table, so a new one cannot be added without
212
+ * someone deciding what a failed load looks like. Pass `false` for a table
213
+ * whose rows are local and cannot fail.
214
+ */
215
+ loadError: boolean;
216
+ /** Re-runs the failed list request, from the error state's retry button. */
217
+ onRetryLoad?: () => void;
218
+ /**
219
+ * Stable identifier for this table, used to persist per-user column widths in
220
+ * localStorage across reloads. Scoped to the signed-in user (falling back to
221
+ * the active organization). When omitted, manual column resizes are not
222
+ * remembered. Pass the same id you use for `perspectives` so widths track the
223
+ * table, not the page instance.
224
+ */
225
+ tableId?: string;
226
+ /**
227
+ * Row density. `'sm'` = 36px rows, `'md'` = 44px rows. Omitted uses the
228
+ * 32px default (Figma 220:2935 — the tight v2 baseline).
229
+ */
230
+ density?: 'sm' | 'md';
231
+ /** Zebra-stripe alternate rows with the secondary surface (v2 design). */
232
+ striped?: boolean;
233
+ /** Called with the selected row IDs whenever the v2 checkbox-column selection changes. */
234
+ onSelectionChange?: (selectedIds: string[]) => void;
235
+ /**
236
+ * Batch-delete the given row IDs. When provided, selecting more than one row
237
+ * via the checkbox column reveals a grouped-actions bar with a Delete action.
238
+ */
239
+ onBulkDelete?: (ids: string[]) => void | Promise<void>;
240
+ /**
241
+ * Fetch the entire dataset (all pages, ignoring the active perspective's
242
+ * filters) for a whole-table export. Supplied by `useDynamicTablePage`. When
243
+ * present, the toolbar Export button exports every row across all columns;
244
+ * when absent it falls back to the currently-loaded page.
245
+ */
246
+ /**
247
+ * Fetch the whole dataset for the toolbar export. MAY REJECT — a page that
248
+ * fails mid-pagination must abort the export, never shorten it (HEDGE-123).
249
+ *
250
+ * The union with {@link ExportAllResult} must stay in step with the identical
251
+ * prop in `types/index.ts`. It did not, briefly: `types/index.ts` was widened
252
+ * for HEDGE-123 and this declaration was not, which broke every host that
253
+ * spreads `{...table.props}` into this component. `packages/ui`'s own tsc
254
+ * cannot catch that — the hosts live in other packages — so the workspace
255
+ * typecheck is the gate that matters for this prop.
256
+ */
257
+ onExportAll?: () => Promise<any[] | ExportAllResult>;
258
+ tableRef: React.RefObject<HTMLDivElement | null>;
259
+ /** Message to display when data is empty (e.g., "No addresses") */
260
+ emptyMessage?: string;
261
+ columnActions?: (column: ColumnDef, colIndex: number) => ContextMenuAction[];
262
+ rowActions?: (rowData: any, rowIndex: number) => ContextMenuAction[];
263
+ cellActions?: (rowData: any, col: ColumnDef, rowIndex: number, colIndex: number) => ContextMenuAction[];
264
+ actionsRenderer?: (rowData: any, rowIndex: number) => React.ReactNode;
265
+ pagination?: PaginationProps;
266
+ /**
267
+ * The dataset-wide search term currently narrowing `data` (the `SearchBar`'s
268
+ * query). Read-only, used for ONE thing: find-and-replace's scope label says
269
+ * "…in 100 loaded rows (filtered by "abc")", so a loaded-page match count can
270
+ * never be mistaken for a dataset count. Omitted → the clause is omitted.
271
+ */
272
+ searchQuery?: string;
273
+
274
+ // NEW - Perspective management
275
+ /**
276
+ * Array of perspective configurations to display in the perspective tabs/dropdown.
277
+ * Perspectives define saved views with filters, sorting, column visibility, etc.
278
+ *
279
+ * VIRTUAL PERSPECTIVES:
280
+ * Perspectives with IDs starting with '__' (double underscore) are considered
281
+ * "virtual" or "system" perspectives. They are hidden from the UI tabs (via
282
+ * PerspectiveTabs.tsx filter) but can still be active to provide functionality
283
+ * like URL-based filtering.
284
+ *
285
+ * Example virtual perspective: `{ id: '__url_filters__', name: 'Filters from URL', ... }`
286
+ */
287
+ savedPerspectives?: PerspectiveConfig[];
288
+
289
+ /**
290
+ * The ID of the currently active perspective. When controlled by parent component,
291
+ * the table will sync its internal state (filters, sorting, columns) to match
292
+ * the active perspective.
293
+ *
294
+ * CONTROLLED MODE:
295
+ * When both `savedPerspectives` and `activePerspectiveId` are provided, the table
296
+ * operates in controlled mode. The parent component manages perspective state and
297
+ * the table syncs to match.
298
+ *
299
+ * CLEARING PERSPECTIVES:
300
+ * Set to `null` to clear the active perspective. The table will update its internal
301
+ * state but will NOT re-apply the perspective if the parent tries to set it again
302
+ * to the same value (prevents infinite loops when user manually clears filters).
303
+ *
304
+ * VIRTUAL PERSPECTIVES:
305
+ * Virtual perspective IDs (starting with '__') can be used as `activePerspectiveId`
306
+ * to provide hidden functionality without cluttering the UI.
307
+ */
308
+ activePerspectiveId?: string | null;
309
+
310
+ /**
311
+ * The view that opens by default FOR THIS USER (workshop A4). Rendered as a
312
+ * star on the tab and used to disable "Set as my default" on the view that
313
+ * already is one. Per-user by contract: `isDefault` is scoped to `userId`
314
+ * upstream, so this never reflects — or changes — anyone else's choice.
315
+ */
316
+ defaultPerspectiveId?: string | null;
317
+
318
+ /**
319
+ * Views colleagues have published as SHARED TEMPLATES. Offered for copying —
320
+ * never applied to anyone automatically. A copy lands in the user's own space
321
+ * and records which template and which version it came from.
322
+ */
323
+ sharedTemplates?: PerspectiveTemplate[];
324
+
325
+ /**
326
+ * Whether this user may publish a view as a shared template. Comes from the
327
+ * server's own RBAC answer (`perspectives.role_defaults`), so the affordance
328
+ * is absent exactly when the endpoint would refuse it.
329
+ */
330
+ canPublishTemplates?: boolean;
331
+
332
+ /** Roles a template can be published to. Empty unless `canPublishTemplates`. */
333
+ publishableRoles?: Array<{ id: string; name: string }>;
334
+
335
+ /** Default columns to hide when no perspective is active */
336
+ defaultHiddenColumns?: string[];
337
+
338
+ /**
339
+ * How rows are grouped when no perspective is active — the table's DEFAULT
340
+ * VIEW, and the only way a host can open on a grouping without first writing
341
+ * a saved view into the database.
342
+ *
343
+ * It seeds state exactly as `defaultHiddenColumns` does: on mount and on every
344
+ * reset to the base view. A saved perspective replaces it wholesale, so a view
345
+ * saved with no grouping opens with no grouping — the alternative (merging)
346
+ * would make "ungrouped" a thing a user cannot save.
347
+ *
348
+ * Rules naming a column the table does not declare are dropped rather than
349
+ * applied; see `resolvePerspectiveState`.
350
+ */
351
+ defaultGrouping?: GroupRule[];
352
+
353
+ /**
354
+ * ⚠ `DynamicTableProps` IS DECLARED TWICE — here and in `types/index.ts`.
355
+ *
356
+ * They have drifted in BOTH directions, which is how FMS's production build
357
+ * broke: this copy carried `defaultGrouping` and not `renderBody`, the other
358
+ * carried `renderBody` and not `defaultGrouping`, and the component and its
359
+ * consumers each resolved a different one. `tsc` reported a prop that is
360
+ * plainly used as not existing.
361
+ *
362
+ * Reconciled rather than merged: collapsing two ~150-member interfaces is a
363
+ * change worth doing on its own, not while unblocking a deploy. Anything
364
+ * added to one MUST be added to the other until then.
365
+ */
366
+ /** An alternative body renderer — the tile grid. Called only while
367
+ * `viewMode` is not `'table'`. */
368
+ renderBody?: (ctx: TableBodyRenderContext) => React.ReactNode;
369
+
370
+ // DEPRECATED - Keep for backward compatibility (converts to perspectives internally)
371
+ savedFilters?: SavedFilter[];
372
+ activeFilterId?: string | null;
373
+ hiddenColumns?: string[];
374
+
375
+ // Debug mode - shows floating event log panel
376
+ debug?: boolean;
377
+
378
+ // UI visibility configuration
379
+ uiConfig?: TableUIConfig;
380
+
381
+ /** When true, automatically selects the first cell when table receives focus with no existing selection */
382
+ autoSelectOnFocus?: boolean;
383
+
384
+ /**
385
+ * When true, Tab navigation enters edit mode on the target cell (Excel-like behavior).
386
+ * When false, Tab only selects the cell without entering edit mode.
387
+ * @default true
388
+ */
389
+ autoEditOnTab?: boolean;
390
+
391
+ /**
392
+ * Function to load filter suggestions from the server.
393
+ * When provided, the filter popover will fetch suggestions via this function
394
+ * instead of extracting values from currently loaded data.
395
+ * Recommended for large datasets (1000+ rows) to avoid client-side performance issues.
396
+ */
397
+ loadFilterSuggestions?: LoadFilterSuggestions;
398
+
399
+ /**
400
+ * Loads the table's FK lookup sources. When provided, the Configure View
401
+ * drawer shows a "Linked columns" section so the user can attach read-only
402
+ * columns pulled from FK-related records to the active perspective.
403
+ */
404
+ loadLookupSources?: LoadLookupSources;
405
+
406
+ /**
407
+ * Loads the table's one-to-many rollup sources. When provided, the Configure
408
+ * View drawer shows a "Summarised columns" section so the user can attach
409
+ * read-only aggregates over child records ("sum of this folder's cost lines")
410
+ * to the active perspective.
411
+ */
412
+ loadRollupSources?: LoadRollupSources;
413
+
414
+ /**
415
+ * Date/time format presets this table offers. When provided (non-empty), the
416
+ * Configure View drawer shows a "Date format" selector so each perspective can
417
+ * carry its own display format, and the active choice is published to cell
418
+ * renderers via `useTableDateFormat()`. The `value` strings are opaque host
419
+ * tokens the table stores and round-trips but never interprets. Omit to leave
420
+ * date formatting entirely to the host's renderers.
421
+ */
422
+ dateFormatOptions?: { value: string; label: string }[];
423
+
424
+ /**
425
+ * Keyboard shortcuts configuration for row-level actions.
426
+ * Shortcuts only fire when a single cell is selected (not editing, not multi-select).
427
+ */
428
+ keyboardShortcuts?: KeyboardShortcutsConfig;
429
+
430
+ /**
431
+ * Callback fired when a keyboard shortcut triggers a row action.
432
+ * Receives the shortcut id, the row data, and the row index.
433
+ */
434
+ onRowAction?: OnRowAction;
435
+
436
+ /**
437
+ * Refs to adjacent DynamicTable containers for cross-table navigation.
438
+ * ArrowDown at the last row / ArrowUp at the first row moves focus to
439
+ * `next` / `prev`. Tab past the last editable cell also moves to `next`,
440
+ * and Shift+Tab before the first editable cell moves to `prev`.
441
+ */
442
+ siblingTableRefs?: {
443
+ prev?: React.RefObject<HTMLDivElement | null>;
444
+ next?: React.RefObject<HTMLDivElement | null>;
445
+ };
446
+ /**
447
+ * Callback when a row is clicked. Enables clickable row mode with hover highlighting.
448
+ * The callback receives the row index, row data, and the mouse event.
449
+ * Clicks on interactive elements (buttons, inputs, etc.) are excluded.
450
+ */
451
+ onRowClick?: (rowIndex: number, rowData: any, event: React.MouseEvent) => void;
452
+
453
+ /**
454
+ * ID of the row to highlight (same style as hover).
455
+ * When set, the row with matching ID will be visually highlighted and scrolled into view
456
+ * if not already visible. Useful for syncing selection state with external components.
457
+ */
458
+ highlightedRowId?: string | null;
459
+
460
+ /**
461
+ * Width in pixels for the actions column.
462
+ * Increase this when you have more action buttons (e.g., 3+ icons).
463
+ * @default 80
464
+ */
465
+ actionsColumnWidth?: number;
466
+
467
+ /** Enable cell comments and color annotations */
468
+ enableComments?: boolean;
469
+ /** Entity type for annotations. String or function that resolves per-row (e.g., for mixed transport types). */
470
+ commentsEntityType?: string | ((row: any) => string);
471
+ /**
472
+ * B8a — resolve the annotation target for ONE CELL.
473
+ *
474
+ * Defaults to today's behaviour: `{ entityType: commentsEntityType,
475
+ * rowId: String(row[idColumnName]) }`, so no existing consumer changes.
476
+ *
477
+ * Declare it when a row is not one record. On the transport list a row is a
478
+ * unit-leg: `truckPlate` belongs to the leg, `containerNumber` belongs to the
479
+ * container that rides on both legs, `bookingNumber` to the sea leg. Without
480
+ * this, one comment on the container appeared on every leg of that container
481
+ * — "on się teraz powiela w innych miejscach".
482
+ *
483
+ * MUST be a pure function of `(row, col)`; it is called once per rendered
484
+ * cell and once per row × column when the annotation request is built.
485
+ */
486
+ commentsCellTarget?: (row: Record<string, any>, col: ColumnDef) => { entityType: string; rowId: string };
487
+ /** Optional view context label stored as metadata (e.g., "project_sea_containers"). */
488
+ commentsViewContext?: string;
489
+ /** Called after any annotation or comment change (create, update, delete). */
490
+ onAnnotationChange?: () => void;
491
+ /**
492
+ * Server-computed aggregate over the WHOLE filtered dataset (`?aggregate=`).
493
+ * Supplied by `useDynamicTablePage`. When absent, the pinned totals row still
494
+ * renders — folded from the loaded page and labelled as page-scoped, which is
495
+ * the honest answer rather than a silent lie.
496
+ */
497
+ aggregateResult?: AggregateResult | null;
498
+ /** The dataset aggregate request is in flight. */
499
+ aggregateLoading?: boolean;
500
+ /** The dataset aggregate request failed — the footer says so instead of showing 0. */
501
+ aggregateError?: boolean;
502
+ }
503
+
504
+ // ============================================
505
+ // MAIN DYNAMIC TABLE COMPONENT
506
+ // ============================================
507
+ const DynamicTable: React.FC<DynamicTableProps> = ({
508
+ data = [],
509
+ columns = [],
510
+ colHeaders = true,
511
+ rowHeaders = false,
512
+ height = 'auto',
513
+ width = 'auto',
514
+ idColumnName = 'id',
515
+ rowKeyColumn,
516
+ tableName = 'Table Name',
517
+ loadError,
518
+ onRetryLoad,
519
+ tableId,
520
+ density,
521
+ striped = false,
522
+ onSelectionChange,
523
+ onBulkDelete,
524
+ onExportAll,
525
+ tableRef,
526
+ emptyMessage,
527
+ columnActions,
528
+ rowActions,
529
+ cellActions,
530
+ actionsRenderer,
531
+ pagination,
532
+ searchQuery,
533
+ // New perspective props
534
+ savedPerspectives: propSavedPerspectives,
535
+ activePerspectiveId: controlledActivePerspectiveId,
536
+ defaultPerspectiveId,
537
+ sharedTemplates,
538
+ canPublishTemplates = false,
539
+ publishableRoles,
540
+ defaultHiddenColumns = [],
541
+ defaultGrouping = EMPTY_GROUP_RULES,
542
+ // Deprecated props (backward compatibility)
543
+ savedFilters: deprecatedSavedFilters,
544
+ activeFilterId: deprecatedActiveFilterId,
545
+ hiddenColumns: deprecatedHiddenColumns = [],
546
+ debug = false,
547
+ uiConfig = {},
548
+ autoSelectOnFocus = false,
549
+ autoEditOnTab = true,
550
+ loadFilterSuggestions,
551
+ loadLookupSources,
552
+ loadRollupSources,
553
+ dateFormatOptions,
554
+ keyboardShortcuts,
555
+ onRowAction,
556
+ siblingTableRefs,
557
+ onRowClick,
558
+ highlightedRowId,
559
+ actionsColumnWidth: actionsColumnWidthProp = 80,
560
+ enableComments = false,
561
+ commentsEntityType,
562
+ commentsCellTarget,
563
+ commentsViewContext,
564
+ onAnnotationChange,
565
+ aggregateResult = null,
566
+ aggregateLoading = false,
567
+ aggregateError = false,
568
+ renderBody,
569
+ }) => {
570
+ // NOTE: the `appearance` prop is a deprecated no-op — every table renders the
571
+ // v2 design system. The prop is retained in the type only for Tier-3 BC.
572
+
573
+ // -------------------- BACKWARD COMPATIBILITY --------------------
574
+ // Convert deprecated savedFilters to savedPerspectives format
575
+ const savedPerspectives = useMemo<PerspectiveConfig[]>(() => {
576
+ if (propSavedPerspectives) {
577
+ return propSavedPerspectives;
578
+ }
579
+ // Convert old savedFilters to perspectives
580
+ if (deprecatedSavedFilters && deprecatedSavedFilters.length > 0) {
581
+ return deprecatedSavedFilters.map(filter => ({
582
+ id: filter.id,
583
+ name: filter.name,
584
+ color: filter.color,
585
+ columns: {
586
+ visible: columns.map(c => c.data),
587
+ hidden: [] as string[],
588
+ },
589
+ filters: filter.rows,
590
+ sorting: [] as SortRule[],
591
+ grouping: [] as GroupRule[],
592
+ aggregations: [] as AggregationRule[],
593
+ lookupColumns: [] as LookupColumnRef[],
594
+ }));
595
+ }
596
+ return [];
597
+ }, [propSavedPerspectives, deprecatedSavedFilters, columns]);
598
+
599
+ // Use new prop or deprecated prop
600
+ const controlledActiveId = controlledActivePerspectiveId !== undefined
601
+ ? controlledActivePerspectiveId
602
+ : deprecatedActiveFilterId;
603
+
604
+ // Merge hidden columns from deprecated prop and new prop
605
+ const initialHiddenColumns = useMemo(() => {
606
+ return [...new Set([...defaultHiddenColumns, ...deprecatedHiddenColumns])];
607
+ }, [defaultHiddenColumns, deprecatedHiddenColumns]);
608
+
609
+ // -------------------- UI CONFIG --------------------
610
+ const {
611
+ hideToolbar = false,
612
+ hideTitle = false,
613
+ searchSuggestions,
614
+ searchDebounceMs,
615
+ searchPlaceholder,
616
+ formatGroupValue,
617
+ hideSearch = false,
618
+ hideAddRowButton = false,
619
+ hidePerspectiveTabs: hidePerspectiveTabsExplicit,
620
+ hidePagination = false,
621
+ hideActionsColumn = false,
622
+ topBarStart,
623
+ topBarEnd,
624
+ searchBarEnd,
625
+ searchBarStart,
626
+ beforeBody,
627
+ enableFullscreen = false,
628
+ onFullscreenChange,
629
+ enableFillHandle = false,
630
+ fillConfirmThreshold = 100,
631
+ readOnlyStyle = 'muted',
632
+ rowHoverStyle = 'default',
633
+ disableBuiltinColumnMenu = false,
634
+ borderless = false,
635
+ bulkActions,
636
+ hideExportButton = false,
637
+ exportFileName,
638
+ enableColumnVirtualization = false,
639
+ columnOverscan,
640
+ hideDensityControl = false,
641
+ expandToolbarActions = false,
642
+ toolbarOverflowExtras,
643
+ densityLevel: densityLevelOverride,
644
+ viewModes,
645
+ onViewModeChange,
646
+ } = uiConfig;
647
+
648
+ // -------------------- DENSITY (PER-USER) --------------------
649
+ // One attribute on the container flips a block of CSS custom properties
650
+ // (styles/density.css) and restyles every mounted cell with ZERO React work.
651
+ // `comfortable` is the default and matches today's rendering exactly, so a
652
+ // user who never opens the picker sees no change at all.
653
+ const { density: densityLevel } = useDensityPreference(densityLevelOverride, tableId);
654
+ // NOTE the distinct attribute name. `data-density` is the LEGACY developer
655
+ // prop ('sm' | 'md'), and `DynamicTable.v2.css` carries rules keyed on
656
+ // `:not([data-density])` that define part of today's DEFAULT rendering —
657
+ // reusing that attribute would switch those rules off the moment a user
658
+ // picked `comfortable`, which is the "opting out is a no-op" guarantee,
659
+ // broken. Spread rather than written literally because the attribute name is
660
+ // owned by types/density.ts, not by this file.
661
+ const densityAttribute = useMemo(
662
+ () => ({ [DENSITY_ATTRIBUTE]: densityLevel }) as Record<string, string>,
663
+ [densityLevel],
664
+ );
665
+
666
+ // Auto-hide perspective tabs when no perspectives are configured
667
+ const hasPerspectives = !!(propSavedPerspectives || deprecatedSavedFilters);
668
+ const hidePerspectiveTabs = hidePerspectiveTabsExplicit ?? !hasPerspectives;
669
+
670
+ // Translator + locale are read HERE, above the column build, because the
671
+ // formula column defs need `t` for their per-cell error tooltips and the
672
+ // footer/summary rows need `locale` for number formatting.
673
+ const t = useT();
674
+ const locale = useLocale();
675
+
676
+ // -------------------- REFS --------------------
677
+ const storeRef = useRef<CellStore | null>(null);
678
+ const dragStateRef = useRef<DragState>({
679
+ isDragging: false,
680
+ type: null,
681
+ start: null,
682
+ });
683
+
684
+ // -------------------- CONSTANTS --------------------
685
+ const actionsColumnWidth = actionsColumnWidthProp;
686
+
687
+ // -------------------- BASE COLUMNS --------------------
688
+ // -------------------- LINKED (LOOKUP) COLUMNS --------------------
689
+ // Read-only columns surfaced from FK-related records, carried by the active
690
+ // perspective. Initialised from the active perspective on mount; updated via
691
+ // the Configure View "Linked columns" section and perspective select.
692
+ const [lookupColumns, setLookupColumns] = useState<LookupColumnRef[]>(() => {
693
+ const activePerspective = controlledActiveId
694
+ ? savedPerspectives.find(p => p.id === controlledActiveId)
695
+ : null;
696
+ return activePerspective?.lookupColumns ?? [];
697
+ });
698
+ const lookupColumnDefs = useMemo(() => buildLookupColumnDefs(lookupColumns), [lookupColumns]);
699
+
700
+ // -------------------- ROLLUP (SUMMARISED) COLUMNS --------------------
701
+ // The one-to-many direction: aggregates over the records that point AT this
702
+ // row. Same lifecycle as linked columns — carried by the active perspective,
703
+ // updated by the Configure View "Summarised columns" section. The VALUE is
704
+ // attached to the row by the host's list route (which sees the `rollups`
705
+ // query param the host derives from these refs); the grid only renders it.
706
+ const [rollupColumns, setRollupColumns] = useState<RollupColumnRef[]>(() => {
707
+ const activePerspective = controlledActiveId
708
+ ? savedPerspectives.find(p => p.id === controlledActiveId)
709
+ : null;
710
+ return activePerspective?.rollupColumns ?? [];
711
+ });
712
+ const rollupColumnDefs = useMemo(
713
+ () => buildRollupColumnDefs(rollupColumns, { locale }),
714
+ [rollupColumns, locale],
715
+ );
716
+
717
+ // -------------------- CALCULATED (FORMULA) COLUMNS --------------------
718
+ // Same lifecycle as linked columns: carried by the active perspective,
719
+ // initialised on mount, updated by the Configure View "Calculated columns"
720
+ // section and by a perspective switch.
721
+ const [formulas, setFormulas] = useState<FormulaColumnRef[]>(() => {
722
+ const activePerspective = controlledActiveId
723
+ ? savedPerspectives.find(p => p.id === controlledActiveId)
724
+ : null;
725
+ return activePerspective?.formulas ?? [];
726
+ });
727
+
728
+ const nativeColumns = useMemo(() => {
729
+ let raw: ColumnDef[];
730
+ if (columns.length > 0) {
731
+ raw = columns;
732
+ } else if (data.length > 0 && typeof data[0] === 'object' && !Array.isArray(data[0])) {
733
+ raw = Object.keys(data[0])
734
+ .filter((k) => k !== '_isNew')
735
+ .map((k) => ({ data: k }));
736
+ } else {
737
+ raw = [];
738
+ }
739
+ // Virtual columns go LAST and in a fixed order (linked, then summarised) so
740
+ // adding one never shifts the index of a native column.
741
+ if (lookupColumnDefs.length === 0 && rollupColumnDefs.length === 0) return raw;
742
+ return [...raw, ...lookupColumnDefs, ...rollupColumnDefs];
743
+ }, [columns, data, lookupColumnDefs, rollupColumnDefs]);
744
+
745
+ // A formula may reference native and linked columns, never another formula —
746
+ // `fields` is therefore derived from the NON-formula set. Memoised per
747
+ // (formulas, fields): the returned defs own the per-row evaluation cache, so
748
+ // rebuilding them on every render would throw that cache away every frame.
749
+ const formulaColumnDefs = useMemo(
750
+ () => buildFormulaColumnDefs(formulas, { fields: fieldMetaFromColumns(nativeColumns), translate: t }),
751
+ [formulas, nativeColumns, t],
752
+ );
753
+
754
+ const baseColumns = useMemo(
755
+ () => (formulaColumnDefs.length > 0 ? [...nativeColumns, ...formulaColumnDefs] : nativeColumns),
756
+ [nativeColumns, formulaColumnDefs],
757
+ );
758
+
759
+ // -------------------- PERSPECTIVE STATE --------------------
760
+ const initialState = useMemo(() => {
761
+ // Find active perspective
762
+ const activePerspective = controlledActiveId
763
+ ? savedPerspectives.find(p => p.id === controlledActiveId)
764
+ : null;
765
+ return resolvePerspectiveState({
766
+ baseColumns,
767
+ perspective: activePerspective,
768
+ defaultHiddenColumns: initialHiddenColumns,
769
+ defaultGrouping,
770
+ });
771
+ }, []); // Only compute on mount
772
+
773
+ const [visibleColumns, setVisibleColumns] = useState<string[]>(initialState.visibleColumns);
774
+ const [hiddenColumns, setHiddenColumns] = useState<string[]>(initialState.hiddenColumns);
775
+ const [filters, setFilters] = useState<FilterRow[]>(initialState.filters);
776
+ const [sortRules, setSortRules] = useState<SortRule[]>(initialState.sortRules);
777
+ const [groupRules, setGroupRules] = useState<GroupRule[]>(initialState.groupRules);
778
+ /**
779
+ * How this table draws its rows. Held EXACTLY like `groupRules` — seeded from
780
+ * the active perspective, dispatched upward in PERSPECTIVE_CHANGE, persisted
781
+ * by the host's perspective save. It is a property of the view, so it is
782
+ * state of the same kind, in the same place, saved by the same path. Anything
783
+ * else (a `useState` + `localStorage`, as `RfqBoardPage` does for its Kanban
784
+ * toggle) cannot belong to a saved view.
785
+ */
786
+ const [viewMode, setViewMode] = useState<TableViewMode>(initialState.viewMode ?? 'table');
787
+ const [aggregations, setAggregations] = useState<AggregationRule[]>(initialState.aggregations);
788
+ const [conditionalFormats, setConditionalFormats] = useState<ConditionalFormatRule[]>(
789
+ initialState.conditionalFormats,
790
+ );
791
+ const [dateFormat, setDateFormat] = useState<string | undefined>(initialState.dateFormat);
792
+ /**
793
+ * Columns pinned sticky-left, seeded from the view rather than from nothing.
794
+ *
795
+ * HEDGE-102: this was `useState(new Set())` with no reader and no writer, so
796
+ * a pin lived until the next render of the page and was gone on reload. It is
797
+ * a property of the view — the same kind of state as `viewMode` and
798
+ * `conditionalFormats` above — so it is seeded, applied and saved by the same
799
+ * paths those use.
800
+ */
801
+ const [frozenColumns, setFrozenColumns] = useState<Set<string>>(
802
+ () => new Set(initialState.frozenColumns),
803
+ );
804
+ const [internalActivePerspectiveId, setInternalActivePerspectiveId] = useState<string | null>(
805
+ controlledActiveId ?? null
806
+ );
807
+
808
+ // Active perspective ID (controlled or internal)
809
+ const activePerspectiveId = controlledActiveId !== undefined
810
+ ? controlledActiveId
811
+ : internalActivePerspectiveId;
812
+
813
+ // `initialState` above is computed on mount only, but `savedPerspectives` and
814
+ // `controlledActiveId` arrive asynchronously — the host fetches them from
815
+ // /api/perspectives/:tableId. So on mount they are empty/null and the grid
816
+ // always initialises to the DEFAULT column set, while the toolbar goes on to
817
+ // render the saved view's tab as active. Without the sync below, that desync
818
+ // persists until the user manually clicks the tab: saved columns, filters and
819
+ // sorting silently never apply, and returning from a detail page looks like
820
+ // the view "reset to Default".
821
+ //
822
+ // Keyed on the perspective ID, never on the `savedPerspectives` array
823
+ // identity — the host rebuilds that array on every refetch, and re-applying
824
+ // on identity change would stomp on the user's in-session edits.
825
+ //
826
+ // THIS IS THE ONLY EFFECT THAT APPLIES A PERSPECTIVE. A second "sync controlled
827
+ // props" effect used to live further down the file, copying the perspective's
828
+ // raw fields without `defaultHiddenColumns`. Both ran on the same commit, so
829
+ // which one won was an effect-ordering accident. Do not add another.
830
+ const appliedPerspectiveRef = useRef<string | null | undefined>(undefined);
831
+ useEffect(() => {
832
+ if (appliedPerspectiveRef.current === activePerspectiveId) return;
833
+ // Nothing to apply yet: an id is set but its config hasn't loaded. Stay put
834
+ // rather than resetting to defaults, and retry once the fetch lands.
835
+ const perspective = activePerspectiveId
836
+ ? savedPerspectives.find(p => p.id === activePerspectiveId)
837
+ : null;
838
+ if (activePerspectiveId && !perspective) return;
839
+
840
+ appliedPerspectiveRef.current = activePerspectiveId;
841
+ const next = resolvePerspectiveState({
842
+ baseColumns,
843
+ perspective,
844
+ defaultHiddenColumns: initialHiddenColumns,
845
+ defaultGrouping,
846
+ });
847
+ setVisibleColumns(next.visibleColumns);
848
+ setHiddenColumns(next.hiddenColumns);
849
+ setFilters(next.filters);
850
+ setSortRules(next.sortRules);
851
+ setGroupRules(next.groupRules);
852
+ setViewMode(next.viewMode ?? 'table');
853
+ setAggregations(next.aggregations);
854
+ setLookupColumns(next.lookupColumns);
855
+ setRollupColumns(next.rollupColumns);
856
+ setFormulas(next.formulas);
857
+ setConditionalFormats(next.conditionalFormats);
858
+ setFrozenColumns(new Set(next.frozenColumns));
859
+ setDateFormat(next.dateFormat);
860
+ // Keep the internal mirror in step while the parent controls the id, so a
861
+ // later switch to uncontrolled mode does not resurrect a stale view.
862
+ if (controlledActiveId !== undefined) {
863
+ setInternalActivePerspectiveId(activePerspectiveId ?? null);
864
+ }
865
+ }, [activePerspectiveId, controlledActiveId, savedPerspectives, baseColumns, initialHiddenColumns, defaultGrouping]);
866
+
867
+ // Display name: use perspective name if selected (except for built-in perspectives starting with '_'), otherwise default tableName
868
+ const displayTableName = useMemo(() => {
869
+ if (activePerspectiveId && !activePerspectiveId.startsWith('_')) {
870
+ const activePerspective = savedPerspectives.find(p => p.id === activePerspectiveId);
871
+ // The base-view row is a personalization of the "Default view" TAB, not a
872
+ // named view — its reserved internal name must never reach the heading.
873
+ if (activePerspective && !activePerspective.isBaseView) {
874
+ return activePerspective.name;
875
+ }
876
+ }
877
+ return tableName;
878
+ }, [activePerspectiveId, savedPerspectives, tableName]);
879
+
880
+ // -------------------- MODERN LAYOUT STATE --------------------
881
+ const [configPanelOpen, setConfigPanelOpen] = useState(false);
882
+ const [configPanelInitialSection, setConfigPanelInitialSection] = useState<string | null>(null);
883
+ // Perspective currently open for editing in the Configure View panel. Null when
884
+ // the panel was opened to create a brand-new view ("Add view").
885
+ const [editingPerspectiveId, setEditingPerspectiveId] = useState<string | null>(null);
886
+ /**
887
+ * Whether the Configure View drawer is currently configuring the BASE
888
+ * ("Default view") tab. Explicit state rather than "no view is active",
889
+ * because "Add view" is also reached from that state and must keep demanding
890
+ * a name for the new view.
891
+ */
892
+ const [configureBaseView, setConfigureBaseView] = useState(false);
893
+ /** This user's saved personalization of the base tab, if any. */
894
+ const baseViewPerspective = useMemo(
895
+ () => savedPerspectives.find((p) => p.isBaseView) ?? null,
896
+ [savedPerspectives],
897
+ );
898
+
899
+ // -------------------- COMMENTS STATE --------------------
900
+ const [commentDialog, setCommentDialog] = useState<{
901
+ /**
902
+ * B8a — the target this dialog writes to, resolved from the CELL, not from
903
+ * the row. On a leg-scoped column it is the leg; on a container-scoped
904
+ * column, the container. Carried explicitly so the dialog never has to
905
+ * re-derive it from the row and land on a different record.
906
+ */
907
+ entityType: string;
908
+ rowId: string;
909
+ columnKey: string;
910
+ columnTitle: string;
911
+ rowLabel?: string;
912
+ annotationId?: string | null;
913
+ currentColor?: string | null;
914
+ anchorRect?: DOMRect | null;
915
+ /** Bulk cells carry their own scope — one range can span two entity types. */
916
+ bulkCells?: { entityType: string; rowId: string; columnKey: string }[];
917
+ } | null>(null);
918
+
919
+ // Read-only hover preview of a commented cell's thread (shown on hovering the
920
+ // comment indicator; click opens the full dialog above).
921
+ const [commentHover, setCommentHover] = useState<{
922
+ comments: AnnotationComment[];
923
+ columnTitle?: string;
924
+ anchorRect: DOMRect;
925
+ } | null>(null);
926
+
927
+
928
+ // -------------------- FULLSCREEN STATE --------------------
929
+ const [isFullscreen, setIsFullscreen] = useState(false);
930
+
931
+ // -------------------- COMPUTED COLUMNS (ordered by perspective) --------------------
932
+ const cols = useMemo(() => {
933
+ // Get columns in the order specified by visibleColumns
934
+ const orderedCols: ColumnDef[] = [];
935
+ for (const key of visibleColumns) {
936
+ const col = baseColumns.find(c => c.data === key);
937
+ if (col) {
938
+ orderedCols.push(col);
939
+ }
940
+ }
941
+ return orderedCols;
942
+ }, [baseColumns, visibleColumns]);
943
+
944
+ /**
945
+ * B8a — the annotation target of one cell. Everything that reads or writes an
946
+ * annotation goes through this ONE resolver, so the fetch, the render, the
947
+ * dialog and the bulk write can never disagree about which record a comment
948
+ * belongs to.
949
+ */
950
+ const resolveCommentTarget = useCallback(
951
+ (rowData: Record<string, any> | undefined, col: ColumnDef): { entityType: string; rowId: string } => {
952
+ if (!rowData) return { entityType: '', rowId: '' };
953
+ if (commentsCellTarget) return commentsCellTarget(rowData, col);
954
+ const entityType =
955
+ typeof commentsEntityType === 'function' ? commentsEntityType(rowData) : commentsEntityType || '';
956
+ return { entityType, rowId: String(rowData[idColumnName] ?? '') };
957
+ },
958
+ [commentsCellTarget, commentsEntityType, idColumnName],
959
+ );
960
+
961
+ /**
962
+ * Every distinct (entityType, rowId) on screen. Computed over rows × VISIBLE
963
+ * columns because a cell's target may depend on its column: a page showing
964
+ * only leg columns must not request container annotations, and one showing
965
+ * both must request both.
966
+ */
967
+ const annotationTargets = useMemo(() => {
968
+ if (!enableComments) return [] as Array<{ entityType: string; rowId: string }>;
969
+ const seen = new Set<string>();
970
+ const out: Array<{ entityType: string; rowId: string }> = [];
971
+ for (const row of data) {
972
+ for (const col of cols) {
973
+ const target = resolveCommentTarget(row, col);
974
+ if (!target.entityType || !target.rowId) continue;
975
+ const key = `${target.entityType}:${target.rowId}`;
976
+ if (seen.has(key)) continue;
977
+ seen.add(key);
978
+ out.push(target);
979
+ }
980
+ }
981
+ return out;
982
+ }, [enableComments, data, cols, resolveCommentTarget]);
983
+
984
+ const { annotations, refresh: refreshAnnotations } = useAnnotations({
985
+ enabled: enableComments && annotationTargets.length > 0,
986
+ targets: annotationTargets,
987
+ });
988
+
989
+ /**
990
+ * The annotation map key for one cell, handed to every row. Stable identity
991
+ * (it only changes when the resolver does) so `VirtualRow`'s `React.memo`
992
+ * survives — a fresh function per render would repaint every mounted row on
993
+ * every keystroke.
994
+ */
995
+ const cellAnnotationKeyAt = useCallback(
996
+ (rowData: Record<string, any>, column: ColumnDef): string | null => {
997
+ const target = resolveCommentTarget(rowData, column);
998
+ if (!target.entityType || !target.rowId) return null;
999
+ return annotationKey(target, column.data);
1000
+ },
1001
+ [resolveCommentTarget],
1002
+ );
1003
+
1004
+ // Compile the highlighting rule set ONCE per change. `Cell` must never
1005
+ // compile: it runs per cell per render, and a 57-column page mounts thousands.
1006
+ const compiledFormats = useMemo(
1007
+ () => compileConditionalFormats(conditionalFormats),
1008
+ [conditionalFormats],
1009
+ );
1010
+
1011
+ // -------------------- STORE INITIALIZATION --------------------
1012
+ if (!storeRef.current) {
1013
+ storeRef.current = createCellStore(data, cols);
1014
+ }
1015
+ const store = storeRef.current;
1016
+
1017
+ // -------------------- GROUPING --------------------
1018
+ // `totalRows` is not optional in practice: without it EVERY subtotal is
1019
+ // labelled "this page only", because the hook cannot prove the loaded page is
1020
+ // the whole filtered dataset.
1021
+ const groupingResult = useGrouping(data, groupRules, cols, aggregations, {
1022
+ totalRows: pagination?.total,
1023
+ });
1024
+ const isGrouped = groupingResult.visualRows !== null;
1025
+
1026
+ /**
1027
+ * Is an alternative body renderer drawing the rows right now?
1028
+ *
1029
+ * When it is, the SPREADSHEET affordances are turned off — the cell-range
1030
+ * drag, the fill handle, the clipboard grid, the `role="grid"` semantics.
1031
+ * Not because they would crash (every one of them bails when it cannot find a
1032
+ * `td[data-row]`), but because leaving them armed would announce a grid with
1033
+ * rows and columns to a screen reader that is looking at a wall of tiles.
1034
+ *
1035
+ * Everything ABOVE and BELOW the body stays: the toolbar, the search box, the
1036
+ * filters, the perspective tabs, the selection bar, the pagination. That is
1037
+ * what makes the two renderers structurally unable to diverge — they are not
1038
+ * two views of the data, they are one view with two bodies.
1039
+ */
1040
+ // Report the mode the table actually settled on — the initial one, and any
1041
+ // restored from a perspective, neither of which goes through the switcher.
1042
+ const reportedViewMode = useRef<TableViewMode | null>(null);
1043
+ useEffect(() => {
1044
+ if (reportedViewMode.current === viewMode) return;
1045
+ reportedViewMode.current = viewMode;
1046
+ onViewModeChange?.(viewMode);
1047
+ }, [viewMode, onViewModeChange]);
1048
+
1049
+ const usingCustomBody = viewMode !== 'table' && !!renderBody;
1050
+
1051
+ // -------------------- OTHER STATE --------------------
1052
+ const [rowCount, setRowCount] = useState(store.getRowCount());
1053
+ const [storeRevision, setStoreRevision] = useState(0);
1054
+ // SINGLE SOURCE OF TRUTH FOR SORT.
1055
+ // `sortRules` is the only sort state: it is what the perspective carries, what
1056
+ // the Configure View drawer edits, what the column menu writes and what the
1057
+ // server query is built from. `sortState` is a pure projection of its primary
1058
+ // rule onto the header's column index — never stored, so a header click and a
1059
+ // menu sort can no longer disagree (which is what made a sorted column render
1060
+ // no indicator at all).
1061
+ const sortState = useMemo<SortState>(() => {
1062
+ const primary = sortRules[0];
1063
+ if (!primary) return { columnIndex: null, direction: null };
1064
+ const columnIndex = cols.findIndex((c) => c.data === primary.field);
1065
+ if (columnIndex < 0) return { columnIndex: null, direction: null };
1066
+ return { columnIndex, direction: primary.direction };
1067
+ }, [sortRules, cols]);
1068
+ const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
1069
+
1070
+ // -------------------- COMPUTED VALUES --------------------
1071
+ const { leftOffsets, rightOffsets } = useStickyOffsets(cols, store, rowHeaders);
1072
+
1073
+ // Only reserve the Actions column when it actually has something to render:
1074
+ // row actions (the kebab), a custom actions renderer, or an in-progress new
1075
+ // row (whose save button lives in this column). Read-only/display tables with
1076
+ // none of these drop the empty column entirely. Recomputed on every render;
1077
+ // adding/removing a new row bumps `storeRevision` (see setStoreRevision),
1078
+ // which re-renders and re-reads `store.hasNewRows()` so the column toggles.
1079
+ const showActionsColumn = !hideActionsColumn && (!!rowActions || !!actionsRenderer || store.hasNewRows());
1080
+
1081
+ // Sticky-column scroll shadows (v2 affordance — CSS scopes them):
1082
+ // • Actions column gets a LEFT-edge shadow while there's content still to
1083
+ // reveal to the right (not scrolled all the way right).
1084
+ // • First (sticky) column gets a RIGHT-edge shadow while there's content
1085
+ // hidden to the left (i.e. scrolled right at all → can scroll back left).
1086
+ const [actionsScrollShadow, setActionsScrollShadow] = useState(false);
1087
+ const [firstColScrollShadow, setFirstColScrollShadow] = useState(false);
1088
+ // Frozen-column float shadow: ON only once the pinned block has actually
1089
+ // reached its sticky point (content scrolling UNDER its right edge) — not
1090
+ // while a not-yet-pinned frozen column is still drifting left with the scroll.
1091
+ const [frozenColShadow, setFrozenColShadow] = useState(false);
1092
+ useEffect(() => {
1093
+ const el = tableRef.current;
1094
+ if (!el) return;
1095
+ // The `querySelector` + `getComputedStyle` + two `getBoundingClientRect`
1096
+ // calls below each force a synchronous style/layout flush, and this fires on
1097
+ // EVERY scroll event — vertical included, since it is one scroller. That is
1098
+ // the same scroll-path DOM work this file documents removing at Defect 7.
1099
+ //
1100
+ // Two changes keep it off the scroll path: the frozen edge and its sticky
1101
+ // target are measured ONCE per effect run (both only change with
1102
+ // `frozenColumns` / `cols.length`, which are already dependencies), and the
1103
+ // handler itself is coalesced onto an animation frame so a burst of scroll
1104
+ // events costs one measurement per painted frame instead of one per event.
1105
+ let frozenEdge: HTMLElement | null = null;
1106
+ let frozenTarget = 0;
1107
+ const measureFrozenEdge = () => {
1108
+ frozenEdge = el.querySelector('.hot-headers-sticky [data-frozen-edge="true"]');
1109
+ frozenTarget = frozenEdge ? parseFloat(getComputedStyle(frozenEdge).left) || 0 : 0;
1110
+ };
1111
+
1112
+ const update = () => {
1113
+ const maxScroll = el.scrollWidth - el.clientWidth;
1114
+ setActionsScrollShadow(maxScroll > 1 && Math.ceil(el.scrollLeft) < maxScroll - 1);
1115
+ setFirstColScrollShadow(el.scrollLeft > 1);
1116
+ // The rightmost pinned column is "stuck" when its rendered left equals its
1117
+ // sticky target (inline `left`); while drifting it sits further right.
1118
+ if (frozenEdge && el.scrollLeft > 1) {
1119
+ const rel = frozenEdge.getBoundingClientRect().left - el.getBoundingClientRect().left;
1120
+ setFrozenColShadow(rel <= frozenTarget + 1);
1121
+ } else {
1122
+ setFrozenColShadow(false);
1123
+ }
1124
+ };
1125
+
1126
+ let frame: number | null = null;
1127
+ const onScroll = () => {
1128
+ if (frame !== null) return;
1129
+ frame = requestAnimationFrame(() => {
1130
+ frame = null;
1131
+ update();
1132
+ });
1133
+ };
1134
+
1135
+ measureFrozenEdge();
1136
+ update();
1137
+ el.addEventListener('scroll', onScroll, { passive: true });
1138
+ // A resize can add or remove the frozen edge, so re-measure before updating.
1139
+ const ro = new ResizeObserver(() => {
1140
+ measureFrozenEdge();
1141
+ onScroll();
1142
+ });
1143
+ ro.observe(el);
1144
+ return () => {
1145
+ el.removeEventListener('scroll', onScroll);
1146
+ if (frame !== null) cancelAnimationFrame(frame);
1147
+ ro.disconnect();
1148
+ };
1149
+ // isFullscreen: entering/leaving fullscreen re-parents tableContent (the
1150
+ // scroller remounts), so re-attach the listener to the new node.
1151
+ // frozenColumns: re-measure engagement when the pinned set changes.
1152
+ }, [tableRef, cols.length, showActionsColumn, isFullscreen, frozenColumns]);
1153
+
1154
+ const totalWidth = useMemo(() => {
1155
+ return (
1156
+ cols.reduce((sum, _, idx) => sum + store.getColumnWidth(idx), 0) +
1157
+ (rowHeaders ? 50 : 0) +
1158
+ (showActionsColumn ? actionsColumnWidth : 0)
1159
+ );
1160
+ }, [cols, store, rowHeaders, actionsColumnWidth, showActionsColumn, storeRevision]);
1161
+
1162
+ /** Row furniture that sits outside the column widths. */
1163
+ const furnitureWidth = (rowHeaders ? 50 : 0) + (showActionsColumn ? actionsColumnWidth : 0);
1164
+
1165
+ // Stable width getter for the pinned totals row, so its `memo` survives a
1166
+ // render that changed nothing it cares about. Widths only move when the store
1167
+ // revision does, which is exactly what invalidates this.
1168
+ const getColumnWidth = useCallback(
1169
+ (colIndex: number) => store.getColumnWidth(colIndex),
1170
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1171
+ [store, storeRevision],
1172
+ );
1173
+
1174
+ // Page-scoped fallback for the pinned totals: folded with the SAME function
1175
+ // the group subtotals use, so the two can never disagree. It is shown
1176
+ // immediately (so the footer is never blank) and `FooterTotalsRow` labels it
1177
+ // honestly as page-scoped until the server's dataset figure lands.
1178
+ const pageAggregates = useMemo(() => {
1179
+ if (aggregations.length === 0) return undefined;
1180
+ const columnsByField = new Map(cols.map((c) => [c.data, c] as const));
1181
+ const indices = data.map((_, i) => i);
1182
+ return computeAggregates(data, indices, aggregations, columnsByField);
1183
+ }, [data, aggregations, cols]);
1184
+ const pageAggregateValues = pageAggregates?.values;
1185
+ const pageAggregateBreakdowns = pageAggregates?.breakdowns;
1186
+
1187
+ // When grouped, virtualizer count is visual rows length; otherwise original row count
1188
+ const virtualizerCount = isGrouped ? groupingResult.visualRows!.length : rowCount;
1189
+
1190
+ // Row height by density: sm=36, md=44; default 32 (Figma 220:2935 — tighter
1191
+ // baseline for all v2 tables). Includes the 4px inter-row gap (2px transparent
1192
+ // border top + bottom) so the visible row pill is ~28px, matching Figma.
1193
+ // The user's density preference wins when they have actually expressed one.
1194
+ // At `comfortable` (the default) this is the ORIGINAL expression, unchanged —
1195
+ // that is what makes shipping density a pixel-level no-op for everyone who
1196
+ // never opens the picker. Row height is the single number CSS cannot own:
1197
+ // rows are absolutely positioned by the virtualizer, so JS has to know it.
1198
+ const dataRowHeight =
1199
+ densityLevel !== 'comfortable'
1200
+ ? resolveDensityRowHeight(densityLevel)
1201
+ : density === 'md' ? 44 : density === 'sm' ? 36 : 32;
1202
+
1203
+ // Row selection (v2 checkbox column for bulk / grouped actions).
1204
+ // Selection identity must be unique per visual row — fall back to
1205
+ // `idColumnName`, but allow consumers to point at a distinct unique column
1206
+ // when `idColumnName` is a repeating entity key (see `rowKeyColumn`).
1207
+ const selectionColumnName = rowKeyColumn ?? idColumnName;
1208
+ const selectable = rowHeaders;
1209
+ const [selectedRowIds, setSelectedRowIds] = useState<Set<string>>(new Set());
1210
+
1211
+ // v2 Actions column shows a kebab (•••) menu built from `rowActions`, unless
1212
+ // the consumer supplies a custom `actionsRenderer`.
1213
+ const showRowActionsMenu = !!rowActions && !actionsRenderer;
1214
+ const pageRowIds = useMemo(
1215
+ () => data
1216
+ .map((r) => (r == null ? undefined : r[selectionColumnName]))
1217
+ .filter((id) => id != null)
1218
+ .map((id) => String(id)),
1219
+ [data, selectionColumnName]
1220
+ );
1221
+ const allSelected = pageRowIds.length > 0 && pageRowIds.every((id) => selectedRowIds.has(id));
1222
+ const someSelected = !allSelected && pageRowIds.some((id) => selectedRowIds.has(id));
1223
+ const handleToggleRowSelect = useCallback((rowId: string) => {
1224
+ setSelectedRowIds((prev) => {
1225
+ const next = new Set(prev);
1226
+ if (next.has(rowId)) next.delete(rowId); else next.add(rowId);
1227
+ onSelectionChange?.(Array.from(next));
1228
+ return next;
1229
+ });
1230
+ }, [onSelectionChange]);
1231
+ const handleToggleSelectAll = useCallback(() => {
1232
+ setSelectedRowIds((prev) => {
1233
+ const next = new Set(prev);
1234
+ const everySelected = pageRowIds.length > 0 && pageRowIds.every((id) => next.has(id));
1235
+ if (everySelected) pageRowIds.forEach((id) => next.delete(id));
1236
+ else pageRowIds.forEach((id) => next.add(id));
1237
+ onSelectionChange?.(Array.from(next));
1238
+ return next;
1239
+ });
1240
+ }, [pageRowIds, onSelectionChange]);
1241
+ // Toggle every row in a group: select all unless they're already all selected.
1242
+ const handleToggleGroupSelect = useCallback((groupRowIds: string[]) => {
1243
+ setSelectedRowIds((prev) => {
1244
+ const next = new Set(prev);
1245
+ const everySelected = groupRowIds.length > 0 && groupRowIds.every((id) => next.has(id));
1246
+ if (everySelected) groupRowIds.forEach((id) => next.delete(id));
1247
+ else groupRowIds.forEach((id) => next.add(id));
1248
+ onSelectionChange?.(Array.from(next));
1249
+ return next;
1250
+ });
1251
+ }, [onSelectionChange]);
1252
+
1253
+ // Grouped (bulk) actions — surfaced when >1 row is selected.
1254
+ const [isBulkDeleting, setIsBulkDeleting] = useState(false);
1255
+ // Holds the last visible selection count so the bar can finish its close
1256
+ // animation showing the real number instead of flashing "1 selected".
1257
+ const bulkBarCountRef = useRef(0);
1258
+ const clearSelection = useCallback(() => {
1259
+ setSelectedRowIds(new Set());
1260
+ onSelectionChange?.([]);
1261
+ }, [onSelectionChange]);
1262
+ const handleBulkDelete = useCallback(async () => {
1263
+ if (!onBulkDelete) return;
1264
+ const ids = Array.from(selectedRowIds);
1265
+ if (ids.length === 0) return;
1266
+ setIsBulkDeleting(true);
1267
+ try {
1268
+ await onBulkDelete(ids);
1269
+ // Resolved → rows deleted; drop the now-stale selection.
1270
+ clearSelection();
1271
+ } catch {
1272
+ // Rejected → cancelled from the confirm modal; keep the selection.
1273
+ } finally {
1274
+ setIsBulkDeleting(false);
1275
+ }
1276
+ }, [onBulkDelete, selectedRowIds, clearSelection]);
1277
+
1278
+ const rowVirtualizer = useVirtualizer({
1279
+ count: virtualizerCount,
1280
+ getScrollElement: () => tableRef?.current,
1281
+ // Group headers share the data-row height so the grouped band lines up
1282
+ // with the rest of the rows (same pill height + inter-row gap).
1283
+ estimateSize: () => dataRowHeight,
1284
+ overscan: 10,
1285
+ });
1286
+
1287
+ // Row height is the one density value CSS cannot own (rows are absolutely
1288
+ // positioned by the virtualiser, which caches item sizes), so a density
1289
+ // switch must invalidate that cache SYNCHRONOUSLY — before paint. The 50ms
1290
+ // timer below is for the fullscreen re-parent, which genuinely needs the DOM
1291
+ // to settle; using it here would paint one frame of overlapping rows.
1292
+ useLayoutEffect(() => {
1293
+ rowVirtualizer.measure();
1294
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1295
+ }, [dataRowHeight]);
1296
+
1297
+ // Re-measure when fullscreen state or row density changes
1298
+ useEffect(() => {
1299
+ // Small delay to ensure DOM is ready after fullscreen transition
1300
+ const timer = setTimeout(() => {
1301
+ rowVirtualizer.measure();
1302
+ }, 50);
1303
+ return () => clearTimeout(timer);
1304
+ }, [isFullscreen, dataRowHeight, rowVirtualizer]);
1305
+
1306
+ const virtualRows = rowVirtualizer.getVirtualItems();
1307
+
1308
+ // -------------------- HANDLERS --------------------
1309
+ // Anchor Defect 5: these factories return the callbacks that are passed into
1310
+ // every mounted `VirtualRow` (`onCellSave`, `onSaveNewRow`, `onCancelNewRow`,
1311
+ // `onRowHeaderDoubleClick`). Constructing them in the render body gave each a
1312
+ // fresh identity on EVERY render, which defeated `VirtualRow`'s `React.memo`
1313
+ // outright — a keystroke in the filter box re-rendered every mounted row and
1314
+ // therefore every mounted cell.
1315
+ //
1316
+ // They are memoised over exactly what they close over. `store`, `tableRef`
1317
+ // and `dragStateRef` are stable for the table's lifetime; `cols` changes only
1318
+ // on a perspective reorder/show-hide, which genuinely must rebuild them (the
1319
+ // handlers index into `cols`). No mutable state is captured, so there is no
1320
+ // stale-closure hazard here — a factory that captured React state instead of
1321
+ // reading it from the store would need a ref, not a wider dep list.
1322
+ const { handleCellSave: handleCellSaveBase } = useMemo(
1323
+ () => createCellHandlers(store, cols, tableRef, idColumnName),
1324
+ [store, cols, tableRef, idColumnName],
1325
+ );
1326
+ const hasFormulaColumns = useMemo(() => cols.some((c) => isFormulaColumnKey(c.data)), [cols]);
1327
+ // A formula cell reads OTHER columns of its row, but `setCellValue` bumps only
1328
+ // the edited cell's revision — so editing an input left the calculated column
1329
+ // showing a stale number until something else forced a repaint. Repainting the
1330
+ // row is the fix; widening `setCellValue` would make every write pay for a
1331
+ // feature almost no view uses. The evaluator's cache is already correct under
1332
+ // in-place mutation, so this is purely a repaint trigger.
1333
+ const handleCellSave = useCallback(
1334
+ (row: number, col: number, newValue: any, clearEditing: boolean = true) => {
1335
+ handleCellSaveBase(row, col, newValue, clearEditing);
1336
+ if (hasFormulaColumns) store.bumpRowRevisions(row, cols.length);
1337
+ },
1338
+ [handleCellSaveBase, hasFormulaColumns, store, cols.length],
1339
+ );
1340
+ const { handleAddRow, handleSaveNewRow, handleCancelNewRow } = useMemo(
1341
+ () => createRowHandlers(store, cols, tableRef),
1342
+ [store, cols, tableRef],
1343
+ );
1344
+ const dragHandlers = useMemo(
1345
+ () => createDragHandlers(store, cols, dragStateRef),
1346
+ [store, cols],
1347
+ );
1348
+ const { handleMouseDown, handleMouseMove, handleMouseUp, handleDoubleClick } = useMemo(
1349
+ () => createMouseHandlers(store, cols, dragStateRef, dragHandlers),
1350
+ [store, cols, dragHandlers],
1351
+ );
1352
+
1353
+ // Perspective handlers. Created BEFORE the column-header handlers because the
1354
+ // header's sort click writes through `handleSortRulesChange` — sort has exactly
1355
+ // one owner (see the `sortState` projection above).
1356
+ const {
1357
+ handleColumnVisibilityChange,
1358
+ handleColumnOrderChange,
1359
+ handleFiltersChange,
1360
+ handleSortRulesChange,
1361
+ handleGroupRulesChange,
1362
+ handleAggregationsChange,
1363
+ handleLookupColumnsChange,
1364
+ handleRollupColumnsChange,
1365
+ handleFormulasChange,
1366
+ handleConditionalFormatsChange,
1367
+ handleDateFormatChange,
1368
+ handleSavePerspective,
1369
+ handlePerspectiveSelect,
1370
+ handlePerspectiveRename,
1371
+ handlePerspectiveDelete,
1372
+ handlePerspectiveDuplicate,
1373
+ handlePerspectiveSetDefault,
1374
+ handlePerspectivePublish,
1375
+ handlePerspectiveTemplateCopy,
1376
+ } = useMemo(
1377
+ () =>
1378
+ createPerspectiveHandlers({
1379
+ tableRef,
1380
+ columns: baseColumns,
1381
+ savedPerspectives,
1382
+ activePerspectiveId,
1383
+ setVisibleColumns,
1384
+ setHiddenColumns,
1385
+ setFilters,
1386
+ setSortRules,
1387
+ setGroupRules,
1388
+ setAggregations,
1389
+ setLookupColumns,
1390
+ setRollupColumns,
1391
+ setFormulas,
1392
+ setConditionalFormats,
1393
+ setFrozenColumns,
1394
+ setDateFormat,
1395
+ setInternalActivePerspectiveId,
1396
+ defaultHiddenColumns: initialHiddenColumns,
1397
+ defaultGrouping,
1398
+ }),
1399
+ // The `set*` functions are React state setters (stable by contract) plus
1400
+ // `setInternalActivePerspectiveId`; only the three data inputs can change.
1401
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1402
+ [tableRef, baseColumns, savedPerspectives, activePerspectiveId, initialHiddenColumns, defaultGrouping],
1403
+ );
1404
+
1405
+ /**
1406
+ * "Reset to defaults" on the base tab.
1407
+ *
1408
+ * EMPTIES this user's base-view row rather than deleting it. The upstream
1409
+ * `perspectives` table soft-deletes (sets `deleted_at`) while its unique index
1410
+ * on `(user_id, tenant_id, organization_id, table_id, name)` ignores that
1411
+ * column — so a deleted row keeps owning the reserved `__base__` name and the
1412
+ * NEXT save would fail with a duplicate-key 500, permanently. Verified live
1413
+ * before this was written, not assumed.
1414
+ *
1415
+ * An empty `columnOrder` is exactly "no column personalization": both
1416
+ * `apiToDynamicTable` and `resolvePerspectiveState` already fall back to the
1417
+ * table's own defaults for it, so the row also does not freeze the user at
1418
+ * today's column set the way an explicit list would.
1419
+ */
1420
+ const handleBaseViewReset = useCallback(() => {
1421
+ if (!baseViewPerspective) return;
1422
+ handleSavePerspective({
1423
+ id: baseViewPerspective.id,
1424
+ name: BASE_VIEW_PERSPECTIVE_NAME,
1425
+ isBaseView: true,
1426
+ columns: { visible: [], hidden: [] },
1427
+ filters: [],
1428
+ sorting: [],
1429
+ grouping: [],
1430
+ aggregations: [],
1431
+ lookupColumns: [],
1432
+ rollupColumns: [],
1433
+ formulas: [],
1434
+ conditionalFormats: [],
1435
+ dateFormat: undefined,
1436
+ });
1437
+ // Repaint the coded defaults immediately; the row we just wrote resolves to
1438
+ // the same thing once the host refetches.
1439
+ const next = resolvePerspectiveState({
1440
+ baseColumns,
1441
+ perspective: null,
1442
+ defaultHiddenColumns: initialHiddenColumns,
1443
+ defaultGrouping,
1444
+ });
1445
+ setVisibleColumns(next.visibleColumns);
1446
+ setHiddenColumns(next.hiddenColumns);
1447
+ setFilters(next.filters);
1448
+ setSortRules(next.sortRules);
1449
+ setGroupRules(next.groupRules);
1450
+ setAggregations(next.aggregations);
1451
+ setLookupColumns(next.lookupColumns);
1452
+ setRollupColumns(next.rollupColumns);
1453
+ setFormulas(next.formulas);
1454
+ setConditionalFormats(next.conditionalFormats);
1455
+ setFrozenColumns(new Set(next.frozenColumns));
1456
+ setDateFormat(next.dateFormat);
1457
+ }, [baseViewPerspective, handleSavePerspective, baseColumns, initialHiddenColumns, defaultGrouping]);
1458
+
1459
+ // Header click / column menu / Configure View drawer all land here.
1460
+ const handleSortDirectionChange = useCallback(
1461
+ (colIndex: number, direction: 'asc' | 'desc' | null) => {
1462
+ const col = cols[colIndex];
1463
+ if (!col) return;
1464
+ handleSortRulesChange(
1465
+ direction === null
1466
+ ? []
1467
+ : [{ id: generateSortRuleId(), field: col.data, direction }],
1468
+ );
1469
+ },
1470
+ [cols, handleSortRulesChange],
1471
+ );
1472
+
1473
+ const { handleColumnSort, handleColumnHeaderDoubleClick, handleColumnHeaderMouseDown } = useMemo(
1474
+ () =>
1475
+ createColumnHeaderHandlers(
1476
+ store,
1477
+ cols,
1478
+ tableRef,
1479
+ sortState,
1480
+ handleSortDirectionChange,
1481
+ setContextMenu,
1482
+ columnActions,
1483
+ ),
1484
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1485
+ [store, cols, tableRef, sortState, handleSortDirectionChange, columnActions],
1486
+ );
1487
+ // `handleRowHeaderDoubleClick` is a `VirtualRow` prop — memoised for the same
1488
+ // reason as the cell handlers. NOTE: `rowActions` is consumer-supplied; a
1489
+ // consumer that passes an inline arrow re-creates this every render, and also
1490
+ // hands `VirtualRow` a fresh `rowActions` prop directly, so the memo is
1491
+ // defeated from the outside. That is a consumer-side fix, not a grid one.
1492
+ const { handleRowHeaderDoubleClick } = useMemo(
1493
+ () => createRowHeaderHandlers(store, tableRef, setContextMenu, rowActions),
1494
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1495
+ [store, tableRef, rowActions],
1496
+ );
1497
+ const { handleContextMenuAction, handleContextMenuClose } = useMemo(
1498
+ () =>
1499
+ createContextMenuHandlers(store, cols, tableRef, contextMenu, setContextMenu, onRowAction),
1500
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1501
+ [store, cols, tableRef, contextMenu, onRowAction],
1502
+ );
1503
+
1504
+ // v2 Actions column: the kebab (•••) button opens the same row menu the
1505
+ // right-click context menu uses, anchored below the button.
1506
+ const handleRowActionsMenu = useCallback((e: React.MouseEvent, rowIndex: number) => {
1507
+ if (!rowActions) return;
1508
+ const rowData = store.getRowData(rowIndex);
1509
+ const actions = rowActions(rowData, rowIndex);
1510
+ if (!actions.length) return;
1511
+ e.preventDefault();
1512
+ e.stopPropagation();
1513
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
1514
+ setContextMenu({ isOpen: true, position: { x: rect.right, y: rect.bottom + 4 }, actions, type: 'row', index: rowIndex });
1515
+ }, [rowActions, store]);
1516
+ // Latest-callback ref so the resize handlers (created here) can persist widths
1517
+ // via the persistence hook, which is set up later in the body (it needs the
1518
+ // ordered `cols` and must register its apply-effect after store.setColumns).
1519
+ const persistColumnWidthRef = useRef<(colIndex: number) => void>(() => {});
1520
+ const { handleResizeStart } = useMemo(
1521
+ () => createResizeHandlers(store, (colIndex) => persistColumnWidthRef.current(colIndex)),
1522
+ [store],
1523
+ );
1524
+ // Columns the user has manually resized — excluded from badge auto-fit so we
1525
+ // never fight a deliberate width.
1526
+ const userResizedColsRef = useRef<Set<number>>(new Set());
1527
+ // Badge auto-fit cache: stable `col.data` key → fitted width. Model-held, so
1528
+ // it answers for unmounted columns and survives a perspective reorder.
1529
+ const badgeFitRef = useRef<Map<string, number>>(new Map());
1530
+
1531
+ // -------------------- FILL THE CONTAINER --------------------
1532
+ // The sum of the DECLARED widths — what the fill measures against. Re-running
1533
+ // the fill when it moves is what makes a hand resize, a badge auto-fit or a
1534
+ // restored saved width redistribute whatever is left over.
1535
+ const declaredTotalWidth = useMemo(
1536
+ () => cols.reduce((sum, _, idx) => sum + store.getBaseColumnWidth(idx), 0) + furnitureWidth,
1537
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1538
+ [cols, store, furnitureWidth, storeRevision],
1539
+ );
1540
+
1541
+ /**
1542
+ * Fill the container when the declared widths do not (see
1543
+ * `utils/columnFill.ts` for the arithmetic and, more importantly, for why it
1544
+ * IS arithmetic rather than `width: 100%`).
1545
+ *
1546
+ * The surplus is written into the store as resolved PIXELS, so every column
1547
+ * keeps an honest declared width — which is precisely what lets column
1548
+ * virtualization stay ON while the grid fills the width. Those two used to be
1549
+ * mutually exclusive.
1550
+ *
1551
+ * No feedback loop, by construction: the target is the scroller's own
1552
+ * `clientWidth`, so a filled row can never widen the scroller, and
1553
+ * `setColumnStretch` notifies nobody when the distribution did not move.
1554
+ */
1555
+ const applyColumnFill = useCallback(() => {
1556
+ const el = tableRef.current;
1557
+ if (!el) return;
1558
+ const rigid = new Set<number>();
1559
+ cols.forEach((col, idx) => {
1560
+ // Pinned / frozen columns keep their exact width (sticky offsets depend
1561
+ // on it), and so does any column the user sized by hand.
1562
+ if (col.sticky || frozenColumns.has(col.data) || userResizedColsRef.current.has(idx)) {
1563
+ rigid.add(idx);
1564
+ }
1565
+ });
1566
+ store.setColumnStretch(
1567
+ computeColumnFill({
1568
+ widths: cols.map((_, idx) => store.getBaseColumnWidth(idx)),
1569
+ rigid,
1570
+ furnitureWidth,
1571
+ containerWidth: el.clientWidth,
1572
+ }),
1573
+ );
1574
+ }, [cols, store, frozenColumns, furnitureWidth, tableRef]);
1575
+
1576
+ useLayoutEffect(() => {
1577
+ applyColumnFill();
1578
+ const el = tableRef.current;
1579
+ if (!el || typeof ResizeObserver === 'undefined') return;
1580
+ // Re-run on container resize: a browser-window resize, the split view
1581
+ // opening, a sidebar collapsing — all of them change what "full width" is.
1582
+ const ro = new ResizeObserver(() => applyColumnFill());
1583
+ ro.observe(el);
1584
+ return () => ro.disconnect();
1585
+ // `isFullscreen` re-parents the scroller, so the observer must move with it.
1586
+ }, [applyColumnFill, declaredTotalWidth, isFullscreen, tableRef]);
1587
+
1588
+ // -------------------- MODERN LAYOUT COLUMN MENU HANDLERS --------------------
1589
+ const handleModernSortAsc = useCallback(
1590
+ (colIndex: number) => handleSortDirectionChange(colIndex, 'asc'),
1591
+ [handleSortDirectionChange],
1592
+ );
1593
+
1594
+ const handleModernSortDesc = useCallback(
1595
+ (colIndex: number) => handleSortDirectionChange(colIndex, 'desc'),
1596
+ [handleSortDirectionChange],
1597
+ );
1598
+
1599
+ const handleModernFilterByField = useCallback((colIndex: number) => {
1600
+ const col = cols[colIndex];
1601
+ if (!col) return;
1602
+ // Add a new empty filter row for this field
1603
+ const newFilter: FilterRow = {
1604
+ id: `filter-${Date.now()}`,
1605
+ field: col.data,
1606
+ operator: 'contains',
1607
+ values: [],
1608
+ };
1609
+ handleFiltersChange([...filters, newFilter]);
1610
+ // Open the configure view panel with filters section expanded. It inherits
1611
+ // the CURRENT context: opened from the base tab, saving keeps the user's
1612
+ // Default view rather than insisting on a name for it.
1613
+ setEditingPerspectiveId(null);
1614
+ setConfigureBaseView(!activePerspectiveId || activePerspectiveId === baseViewPerspective?.id);
1615
+ setConfigPanelInitialSection('filters');
1616
+ setConfigPanelOpen(true);
1617
+ }, [cols, filters, handleFiltersChange, activePerspectiveId, baseViewPerspective]);
1618
+
1619
+ /**
1620
+ * A16 / ledger 7.11 / HEDGE-7 — write a COLUMN LAYOUT change THROUGH to this
1621
+ * user's view.
1622
+ *
1623
+ * Two entry points land here, and they are the two that change the layout
1624
+ * from OUTSIDE the Configure View drawer: a header drag (or its "Move
1625
+ * left/right" menu twin) and the column menu's "Hide field". Both used to
1626
+ * move `visibleColumns`/`hiddenColumns` only, which is in-memory state, so the
1627
+ * change was gone on the next reload and the only way to keep it was to
1628
+ * reopen the drawer and press Save. Column layout is per-user personalization
1629
+ * (decision of 2026-08-03) and each of these IS the user's explicit act —
1630
+ * nothing else should be needed to keep it.
1631
+ *
1632
+ * The drawer deliberately does NOT come through here: it owns a Save/Cancel
1633
+ * pair whose Cancel reverts the live preview, so writing on every toggle
1634
+ * would break the contract the panel documents at its snapshot ref.
1635
+ *
1636
+ * The payload is built from the SAVED row, not from live table state, with
1637
+ * only `columns` replaced. That is the whole point: ad-hoc quick filters stay
1638
+ * ephemeral (A2/D6), so persisting the grid's current `filters`/`sortRules`
1639
+ * here would quietly promote a throwaway funnel tick into the saved view just
1640
+ * because the user nudged a column afterwards.
1641
+ *
1642
+ * With no saved row yet we are necessarily on the base ("Default view") tab —
1643
+ * every named view has a row by construction — so we mint the base row the
1644
+ * same way `handleBaseViewReset` and the drawer's base-view Save do, carrying
1645
+ * ONLY the column layout.
1646
+ *
1647
+ * WHOSE VIEW THIS TOUCHES (the HEDGE-5 worry, answered): a `perspectives` row
1648
+ * is keyed by `(user_id, tenant_id, organization_id, table_id, name)` and the
1649
+ * base row is no exception — the reserved `__base__` name is per user. So the
1650
+ * shared-looking "Default view" tab is in fact this user's own personalization
1651
+ * of it, and an autosave here cannot change what anyone else sees. The only
1652
+ * role-scoped rows are `role_perspectives`, written solely by the explicit
1653
+ * Publish action.
1654
+ */
1655
+ const persistColumnLayout = useCallback((visible: string[], hidden: string[]) => {
1656
+ // Only when a host actually owns perspectives. `controlledActiveId` is
1657
+ // `undefined` exactly when nobody passed `activePerspectiveId` — an
1658
+ // uncontrolled grid (a drawer sub-table, a Storybook story) has nowhere to
1659
+ // save to, and should not mint a base-view row on the first drag.
1660
+ if (controlledActiveId === undefined) return;
1661
+ const target = activePerspectiveId
1662
+ ? savedPerspectives.find(p => p.id === activePerspectiveId) ?? null
1663
+ : baseViewPerspective;
1664
+ if (target) {
1665
+ handleSavePerspective({ ...target, columns: { visible, hidden } }, true);
1666
+ return;
1667
+ }
1668
+ if (activePerspectiveId) return; // a named view whose row hasn't loaded — don't invent one
1669
+ handleSavePerspective(
1670
+ {
1671
+ id: generatePerspectiveId(),
1672
+ name: BASE_VIEW_PERSPECTIVE_NAME,
1673
+ isBaseView: true,
1674
+ columns: { visible, hidden },
1675
+ // FROM THE LIVE STATE — same rule as the view-mode switch below. A
1676
+ // column drag records a column order and a "Hide field" records a
1677
+ // column set; neither is a statement that the user wanted their
1678
+ // filters, sorting and grouping thrown away, and writing empty literals
1679
+ // here made it exactly that.
1680
+ filters,
1681
+ sorting: sortRules,
1682
+ grouping: groupRules,
1683
+ aggregations,
1684
+ lookupColumns,
1685
+ rollupColumns,
1686
+ formulas,
1687
+ conditionalFormats,
1688
+ dateFormat,
1689
+ viewMode,
1690
+ },
1691
+ true,
1692
+ );
1693
+ }, [activePerspectiveId, controlledActiveId, savedPerspectives, baseViewPerspective, handleSavePerspective,
1694
+ filters, sortRules, groupRules, aggregations, lookupColumns, rollupColumns, formulas,
1695
+ conditionalFormats, dateFormat, viewMode]);
1696
+
1697
+ /**
1698
+ * HEDGE-102 — write a FREEZE change through to this user's view.
1699
+ *
1700
+ * Deliberately a sibling of `persistColumnLayout` above rather than a shared
1701
+ * generic: the two agree on how to find the target row, and differ on the one
1702
+ * thing that matters — which key the freshly minted base row carries. Folding
1703
+ * them together would mean a `Partial<PerspectiveConfig>` threaded through a
1704
+ * mint block whose whole point is being explicit about what it writes, and
1705
+ * that block already carries a comment about what writing empty literals here
1706
+ * cost last time.
1707
+ *
1708
+ * Whose view this touches is the same answer as above and it was checked
1709
+ * against the schema, not assumed: `perspectives` is unique on
1710
+ * `(user_id, tenant_id, organization_id, table_id, name)` with `user_id NOT
1711
+ * NULL`, so the shared-looking "Default view" is this user's own row.
1712
+ * `role_perspectives` is the only role-scoped surface and only Publish writes
1713
+ * it — one user pinning a column cannot move anyone else's grid.
1714
+ */
1715
+ const persistFrozenColumns = useCallback((next: Set<string>) => {
1716
+ if (controlledActiveId === undefined) return;
1717
+ const frozen = [...next];
1718
+ const target = activePerspectiveId
1719
+ ? savedPerspectives.find(p => p.id === activePerspectiveId) ?? null
1720
+ : baseViewPerspective;
1721
+ if (target) {
1722
+ handleSavePerspective({ ...target, frozenColumns: frozen }, true);
1723
+ return;
1724
+ }
1725
+ if (activePerspectiveId) return; // a named view whose row hasn't loaded — don't invent one
1726
+ handleSavePerspective(
1727
+ {
1728
+ id: generatePerspectiveId(),
1729
+ name: BASE_VIEW_PERSPECTIVE_NAME,
1730
+ isBaseView: true,
1731
+ columns: { visible: visibleColumns, hidden: hiddenColumns },
1732
+ frozenColumns: frozen,
1733
+ // Same rule as the column-layout mint: pinning a column is not a
1734
+ // statement that the user wanted their filters and sorting discarded.
1735
+ filters,
1736
+ sorting: sortRules,
1737
+ grouping: groupRules,
1738
+ aggregations,
1739
+ lookupColumns,
1740
+ rollupColumns,
1741
+ formulas,
1742
+ conditionalFormats,
1743
+ dateFormat,
1744
+ viewMode,
1745
+ },
1746
+ true,
1747
+ );
1748
+ }, [activePerspectiveId, controlledActiveId, savedPerspectives, baseViewPerspective,
1749
+ handleSavePerspective, visibleColumns, hiddenColumns, filters, sortRules, groupRules,
1750
+ aggregations, lookupColumns, rollupColumns, formulas, conditionalFormats, dateFormat,
1751
+ viewMode]);
1752
+
1753
+ /**
1754
+ * Pin or unpin a column. The user's explicit act, so it saves itself — the
1755
+ * same contract the header drag and "Hide field" got in HEDGE-7.
1756
+ */
1757
+ const handleModernFreezeToggle = useCallback((colIndex: number) => {
1758
+ const col = cols[colIndex];
1759
+ if (!col) return;
1760
+ // `next` is computed OUTSIDE the state updater and the handler depends on
1761
+ // `frozenColumns`, so the closure is always the committed value. Persisting
1762
+ // from inside the updater would have been tidier to read and wrong: React
1763
+ // may invoke an updater twice (StrictMode does in development), and a save
1764
+ // is not a pure function of the previous state.
1765
+ const next = new Set(frozenColumns);
1766
+ if (next.has(col.data)) {
1767
+ next.delete(col.data);
1768
+ } else {
1769
+ next.add(col.data);
1770
+ }
1771
+ setFrozenColumns(next);
1772
+ persistFrozenColumns(next);
1773
+ }, [cols, frozenColumns, persistFrozenColumns]);
1774
+
1775
+ /**
1776
+ * A16 — commit a header drag (or a "Move left/right" menu click).
1777
+ *
1778
+ * Both indices address `cols` — the VIEW order. The write happens on
1779
+ * `visibleColumns`, the persisted key array, and is done by KEY, not by
1780
+ * position: `visibleColumns` may legitimately carry keys that `baseColumns`
1781
+ * has not produced a column for yet (a linked or calculated column added by
1782
+ * the perspective), and those must survive a reorder untouched.
1783
+ *
1784
+ * Column order is PER-USER: it lands in the active view's `columns.visible`,
1785
+ * which the perspectives API stores against this user's row.
1786
+ */
1787
+ const handleColumnReorder = useCallback((fromIndex: number, toIndex: number) => {
1788
+ const fromCol = cols[fromIndex];
1789
+ const toCol = cols[toIndex];
1790
+ if (!fromCol || !toCol || fromIndex === toIndex) return;
1791
+ const fromKey = fromCol.data;
1792
+ const toKey = toCol.data;
1793
+ const fi = visibleColumns.indexOf(fromKey);
1794
+ const ti = visibleColumns.indexOf(toKey);
1795
+ if (fi < 0 || ti < 0 || fi === ti) return;
1796
+ const next = visibleColumns.slice();
1797
+ next.splice(fi, 1);
1798
+ next.splice(ti, 0, fromKey);
1799
+ // Goes through the same writer the Configure View drawer uses, so the host
1800
+ // gets its PERSPECTIVE_CHANGE and the two entry points cannot drift.
1801
+ handleColumnVisibilityChange(next, hiddenColumns);
1802
+ persistColumnLayout(next, hiddenColumns);
1803
+ }, [cols, visibleColumns, hiddenColumns, handleColumnVisibilityChange, persistColumnLayout]);
1804
+
1805
+ /**
1806
+ * HEDGE-7 — "Hide field" in the column menu, written THROUGH to this user's
1807
+ * view.
1808
+ *
1809
+ * This is the reported defect, and it is a MISSING CALL, not a missing
1810
+ * mechanism: hiding a column moved `visibleColumns`/`hiddenColumns` and
1811
+ * stopped there, so nothing ever reached the server and a reload brought the
1812
+ * column straight back. The user got no undo prompt, no dirty marker and no
1813
+ * Save button, so the change looked done — which is exactly how the reporter
1814
+ * described it ("it went back to the earlier settings", not "I didn't save").
1815
+ *
1816
+ * It now takes the same route a "Move left" already takes: `persistColumnLayout`,
1817
+ * silent, into this user's own perspective row. One writer, three callers.
1818
+ *
1819
+ * The mirror action — putting the column back — lives in the Configure View
1820
+ * drawer's "Hide fields" section and persists on that drawer's Save. It is not
1821
+ * routed through here on purpose; see the note on `persistColumnLayout`.
1822
+ */
1823
+ const handleModernHideField = useCallback((colIndex: number) => {
1824
+ const col = cols[colIndex];
1825
+ if (!col) return;
1826
+ // Idempotent: a double-fire must not push the same key into `hiddenColumns`
1827
+ // twice, which would survive the round trip and show up as a duplicate row
1828
+ // in the drawer's field list.
1829
+ if (!visibleColumns.includes(col.data)) return;
1830
+ const newVisible = visibleColumns.filter(k => k !== col.data);
1831
+ const newHidden = hiddenColumns.includes(col.data) ? hiddenColumns : [...hiddenColumns, col.data];
1832
+ handleColumnVisibilityChange(newVisible, newHidden);
1833
+ persistColumnLayout(newVisible, newHidden);
1834
+ }, [cols, visibleColumns, hiddenColumns, handleColumnVisibilityChange, persistColumnLayout]);
1835
+
1836
+ /**
1837
+ * The view switcher — write the chosen mode THROUGH to this user's view.
1838
+ *
1839
+ * Deliberately the same mechanism as `persistColumnLayout`, and for the same
1840
+ * reason: clicking the switcher IS the user's explicit act, and a mode that
1841
+ * is gone on the next reload is not a property of the view, it is a mood.
1842
+ *
1843
+ * The payload is built from the SAVED row with only `viewMode` replaced —
1844
+ * never from live table state — so switching to the grid cannot quietly
1845
+ * promote an ad-hoc quick filter into the saved view. Same trap the column
1846
+ * drag documents above; same answer.
1847
+ *
1848
+ * `silent` because one toast per switch is noise, not feedback. A FAILED
1849
+ * write is still reported.
1850
+ *
1851
+ * With no saved row we are on the base ("Default view") tab by construction,
1852
+ * so the base row is minted carrying only the mode. With no perspectives at
1853
+ * all (an uncontrolled grid — a drawer sub-table, a story) the mode is still
1854
+ * applied, just not persisted: there is nowhere to persist it to, and
1855
+ * refusing to switch would be worse than forgetting.
1856
+ */
1857
+ const handleViewModeChange = useCallback((mode: TableViewMode) => {
1858
+ setViewMode(mode);
1859
+ onViewModeChange?.(mode);
1860
+ if (controlledActiveId === undefined) return;
1861
+ const target = activePerspectiveId
1862
+ ? savedPerspectives.find(p => p.id === activePerspectiveId) ?? null
1863
+ : baseViewPerspective;
1864
+ if (target) {
1865
+ handleSavePerspective({ ...target, viewMode: mode }, true);
1866
+ return;
1867
+ }
1868
+ if (activePerspectiveId) return; // a named view whose row hasn't loaded — don't invent one
1869
+ /**
1870
+ * Mint the base row FROM THE LIVE STATE, not from empty literals.
1871
+ *
1872
+ * This used to write `filters: []`, `sorting: []`, `grouping: []` and a bare
1873
+ * `dateFormat: undefined` alongside the real column lists — so flipping
1874
+ * list↔grid persisted an "everything cleared" view over whatever the user
1875
+ * had on screen, and the next perspective read applied it back. Filters and
1876
+ * sorting survived the round trip only because the host owns those; the
1877
+ * GROUPING did not, and a table that opens grouped lost its sections the
1878
+ * first time anyone touched the view switcher.
1879
+ *
1880
+ * Measured on the documents list: grouped by case in the table, one click
1881
+ * to Grid, and `groupRules` came back `[]`.
1882
+ *
1883
+ * The switcher's job is to record the MODE. Everything else about the view
1884
+ * belongs to the view and travels unchanged.
1885
+ */
1886
+ handleSavePerspective(
1887
+ {
1888
+ id: generatePerspectiveId(),
1889
+ name: BASE_VIEW_PERSPECTIVE_NAME,
1890
+ isBaseView: true,
1891
+ columns: { visible: visibleColumns, hidden: hiddenColumns },
1892
+ filters,
1893
+ sorting: sortRules,
1894
+ grouping: groupRules,
1895
+ aggregations,
1896
+ lookupColumns,
1897
+ rollupColumns,
1898
+ formulas,
1899
+ conditionalFormats,
1900
+ dateFormat,
1901
+ viewMode: mode,
1902
+ },
1903
+ true,
1904
+ );
1905
+ }, [
1906
+ controlledActiveId,
1907
+ activePerspectiveId,
1908
+ savedPerspectives,
1909
+ baseViewPerspective,
1910
+ handleSavePerspective,
1911
+ visibleColumns,
1912
+ hiddenColumns,
1913
+ filters,
1914
+ sortRules,
1915
+ groupRules,
1916
+ aggregations,
1917
+ lookupColumns,
1918
+ rollupColumns,
1919
+ formulas,
1920
+ conditionalFormats,
1921
+ dateFormat,
1922
+ ]);
1923
+
1924
+ /** Keyboard/menu equivalent of the drag: one step left or right. */
1925
+ const handleColumnMove = useCallback((colIndex: number, direction: -1 | 1) => {
1926
+ const target = colIndex + direction;
1927
+ if (target < 0 || target >= cols.length) return;
1928
+ handleColumnReorder(colIndex, target);
1929
+ }, [cols.length, handleColumnReorder]);
1930
+
1931
+ const handleModernColumnAction = useCallback((actionId: string, colIndex: number) => {
1932
+ if (!tableRef?.current) return;
1933
+ dispatch(tableRef.current, TableEvents.COLUMN_CONTEXT_MENU_ACTION, {
1934
+ columnIndex: colIndex,
1935
+ columnName: cols[colIndex]?.data,
1936
+ actionId,
1937
+ });
1938
+ }, [cols, tableRef]);
1939
+
1940
+ // Apply frozen columns as sticky-left by computing adjusted offsets
1941
+ const effectiveLeftOffsets = useMemo(() => {
1942
+ if (frozenColumns.size === 0) return leftOffsets;
1943
+ const offsets = [...leftOffsets];
1944
+ let cumulativeOffset = rowHeaders ? ROW_HEADER_WIDTH : 0;
1945
+ // Check existing sticky-left columns first
1946
+ for (let i = 0; i < cols.length; i++) {
1947
+ if (cols[i].sticky === 'left') {
1948
+ cumulativeOffset = (offsets[i] ?? 0) + store.getColumnWidth(i);
1949
+ }
1950
+ }
1951
+ // Apply frozen columns
1952
+ for (let i = 0; i < cols.length; i++) {
1953
+ if (frozenColumns.has(cols[i].data) && cols[i].sticky !== 'left') {
1954
+ offsets[i] = cumulativeOffset;
1955
+ cumulativeOffset += store.getColumnWidth(i);
1956
+ }
1957
+ }
1958
+ return offsets;
1959
+ }, [frozenColumns, leftOffsets, cols, store, rowHeaders, storeRevision]);
1960
+
1961
+ // Data key of the RIGHTMOST pinned (sticky-left or frozen) column — its right
1962
+ // edge is the boundary the scrolling body slides under, so it carries the
1963
+ // float shadow. Derived from the resolved offsets so it covers both natively
1964
+ // sticky-left columns and menu-frozen ones.
1965
+ const lastFrozenColumnKey = useMemo(() => {
1966
+ let key: string | null = null;
1967
+ for (let i = 0; i < cols.length; i++) {
1968
+ if (effectiveLeftOffsets[i] !== undefined) key = cols[i].data;
1969
+ }
1970
+ return key;
1971
+ }, [effectiveLeftOffsets, cols]);
1972
+
1973
+ // -------------------- COLUMN VIRTUALIZATION --------------------
1974
+ // Opt-in (`uiConfig.enableColumnVirtualization`), default OFF. When off, the
1975
+ // hook returns a FULL window — every column mounted, no spacers — which is
1976
+ // byte-identical to the render this grid has always produced.
1977
+ //
1978
+ // Pinned columns derive from the SAME two sources as `effectiveLeftOffsets`
1979
+ // (`ColumnDef.sticky` + the runtime `frozenColumns` key set), because
1980
+ // `position: sticky` needs an element that exists: anything pinned is mounted
1981
+ // unconditionally.
1982
+ const pinnedColumnIndices = useMemo(
1983
+ () => getPinnedColumnIndices(cols, frozenColumns),
1984
+ [cols, frozenColumns],
1985
+ );
1986
+
1987
+ // The caret and the editor must never land on an unmounted column. Held in
1988
+ // STATE rather than read during render so that an arrow key which stays
1989
+ // inside the same column costs no DynamicTable render at all — this component
1990
+ // deliberately does not subscribe to the selection.
1991
+ const [forcedColumnIndices, setForcedColumnIndices] = useState<number[]>([]);
1992
+
1993
+ const getColumnKeyByIndex = useCallback(
1994
+ (colIndex: number) => cols[colIndex]?.data ?? colIndex,
1995
+ [cols],
1996
+ );
1997
+
1998
+ const columnVirtualizer = useColumnVirtualizer({
1999
+ enabled: enableColumnVirtualization,
2000
+ columnCount: cols.length,
2001
+ getColumnWidth,
2002
+ getScrollElement: () => tableRef.current,
2003
+ pinnedLeftIndices: pinnedColumnIndices.left,
2004
+ pinnedRightIndices: pinnedColumnIndices.right,
2005
+ forcedIndices: forcedColumnIndices,
2006
+ overscan: columnOverscan,
2007
+ // ROW_HEADER_WIDTH (32), NOT the 50 that `totalWidth` uses for the same
2008
+ // gutter — the two disagree today (a pre-existing bug). The 18px difference
2009
+ // moves only the scroll-range arithmetic, well inside one overscan column,
2010
+ // and never touches a spacer width. Do NOT "fix" it by swapping `totalWidth`
2011
+ // for the virtualizer's: that visibly narrows every table by 18px.
2012
+ leadingWidth: rowHeaders ? ROW_HEADER_WIDTH : 0,
2013
+ trailingWidth: showActionsColumn ? actionsColumnWidth : 0,
2014
+ widthRevision: storeRevision,
2015
+ getColumnKey: getColumnKeyByIndex,
2016
+ hasRowHeader: rowHeaders,
2017
+ hasActionsColumn: showActionsColumn,
2018
+ });
2019
+
2020
+ const columnVirtualizerRef = useRef(columnVirtualizer);
2021
+ columnVirtualizerRef.current = columnVirtualizer;
2022
+
2023
+ // Range-operation contract rule 9: MOVING THE SELECTION TO A COLUMN IMPLIES
2024
+ // SCROLLING IT INTO VIEW — in the same commit, before any focus attempt.
2025
+ //
2026
+ // Subscribing to the store rather than patching each `setSelection` /
2027
+ // `setEditingCell` call site covers every path there is (keyboard, Tab,
2028
+ // double-click, add-row, context menu, a consumer calling the store directly)
2029
+ // and cannot be missed by a future one. The callback runs SYNCHRONOUSLY inside
2030
+ // the store notification, so `scrollLeft` is written before React re-renders
2031
+ // and long before `Cell`'s `setTimeout(0)` focus effect runs.
2032
+ //
2033
+ // `setEditingCell` has its own channel: it bumps only the two affected cells'
2034
+ // revisions, so a selection subscriber cannot see the editor move.
2035
+ useEffect(() => {
2036
+ if (!enableColumnVirtualization) return;
2037
+ let lastKey = '';
2038
+ const sync = () => {
2039
+ const sel = store.getSelection();
2040
+ const editing = store.getEditingCell();
2041
+ const next: number[] = [];
2042
+ if (sel.anchor) next.push(sel.anchor.col);
2043
+ if (sel.focus) next.push(sel.focus.col);
2044
+ if (editing) next.push(editing.col);
2045
+ const unique = Array.from(new Set(next)).sort((a, b) => a - b);
2046
+ const key = unique.join(',');
2047
+ if (key !== lastKey) {
2048
+ lastKey = key;
2049
+ setForcedColumnIndices(unique);
2050
+ }
2051
+ // No-op when the target is pinned or already visible.
2052
+ const target = editing?.col ?? sel.focus?.col ?? sel.anchor?.col;
2053
+ if (target != null) columnVirtualizerRef.current.scrollToColumn(target);
2054
+ };
2055
+ sync();
2056
+ const offSelection = store.subscribeToSelection(sync);
2057
+ const offEditing = store.subscribeToEditing(sync);
2058
+ return () => {
2059
+ offSelection();
2060
+ offEditing();
2061
+ };
2062
+ }, [store, enableColumnVirtualization]);
2063
+
2064
+ /**
2065
+ * The VERTICAL companion to the column scroll above: moving the selection to
2066
+ * a row implies scrolling that row into view.
2067
+ *
2068
+ * `Ctrl/Cmd+Shift+ArrowDown` extends to the far edge of the data block, which
2069
+ * on a 50-row page is always outside the ~29 mounted rows. Without this the
2070
+ * extension is correct in the model and invisible on screen — and a keyboard
2071
+ * gesture whose result you cannot see is worse than one that does nothing.
2072
+ *
2073
+ * Bails out for a row that is already MOUNTED rather than relying on
2074
+ * `align: 'auto'`: the virtualizer issues a `scrollTo` either way, and an
2075
+ * unconditional scroll on every click would fight the column scroll-into-view
2076
+ * above and make a drag-select jitter.
2077
+ */
2078
+ useEffect(() => {
2079
+ let lastRow = -1;
2080
+ const sync = () => {
2081
+ const editing = store.getEditingCell();
2082
+ const sel = store.getSelection();
2083
+ const target = editing?.row ?? sel.focus?.row ?? sel.anchor?.row;
2084
+ if (target == null || target < 0 || target >= store.getRowCount()) return;
2085
+ if (target === lastRow) return;
2086
+ lastRow = target;
2087
+ // Already painted → nothing to do. A drag-select's focus can only ever be
2088
+ // a mounted cell, so this is also what keeps a drag from auto-scrolling.
2089
+ const mounted = rowVirtualizer.getVirtualItems();
2090
+ if (mounted.length === 0) return;
2091
+ if (target >= mounted[0].index && target <= mounted[mounted.length - 1].index) return;
2092
+ rowVirtualizer.scrollToIndex(target, { align: 'auto' });
2093
+ };
2094
+ const offSelection = store.subscribeToSelection(sync);
2095
+ const offEditing = store.subscribeToEditing(sync);
2096
+ return () => {
2097
+ offSelection();
2098
+ offEditing();
2099
+ };
2100
+ }, [store, rowVirtualizer]);
2101
+
2102
+ const keyboardHandler = useKeyboardNavigation(store, cols.length, cols, autoEditOnTab, handleCellSave, siblingTableRefs);
2103
+ const shortcutHandler = useRowActionShortcuts(store, keyboardShortcuts, onRowAction);
2104
+
2105
+ // Row click handler using event delegation
2106
+ const handleTableClick = useCallback((e: React.MouseEvent) => {
2107
+ if (!onRowClick) return;
2108
+
2109
+ const target = e.target as HTMLElement;
2110
+
2111
+ // Skip group header rows
2112
+ if (target.closest('tr[data-group-header]')) return;
2113
+
2114
+ // Don't trigger row click if clicking on action buttons, inputs, etc.
2115
+ if (target.closest('button, input, select, textarea, a, [data-no-row-click]')) return;
2116
+
2117
+ // Find the row element
2118
+ const row = target.closest('tr[data-row]');
2119
+ if (!row) return;
2120
+
2121
+ const rowIndex = parseInt(row.getAttribute('data-row') || '', 10);
2122
+ if (isNaN(rowIndex)) return;
2123
+
2124
+ const rowData = store.getRowData(rowIndex);
2125
+ if (rowData) {
2126
+ onRowClick(rowIndex, rowData, e);
2127
+ }
2128
+ }, [store, onRowClick]);
2129
+
2130
+ /**
2131
+ * Every cell of a selection, as annotation TARGETS.
2132
+ *
2133
+ * Iterates the bounds arithmetically — a cell whose column is outside the
2134
+ * virtualization window is still part of the range. De-duplicated on
2135
+ * `entityType:rowId:columnKey`, which is also the annotations table's unique
2136
+ * key: a range covering both legs of one container resolves the container's
2137
+ * columns to the SAME target twice, and the batch endpoint rejects the
2138
+ * duplicate. That de-duplication is now correct rather than accidental —
2139
+ * before B8a it collapsed two genuinely different legs into one cell.
2140
+ */
2141
+ const collectBulkCells = useCallback(
2142
+ (bounds: { startRow: number; endRow: number; startCol: number; endCol: number }) => {
2143
+ const cells: { entityType: string; rowId: string; columnKey: string }[] = [];
2144
+ const seen = new Set<string>();
2145
+ for (let r = bounds.startRow; r <= bounds.endRow; r++) {
2146
+ const rData = store.getRowData(r);
2147
+ if (!rData) continue;
2148
+ for (let c = bounds.startCol; c <= bounds.endCol; c++) {
2149
+ const cConfig = cols[c];
2150
+ if (!cConfig) continue;
2151
+ const target = resolveCommentTarget(rData, cConfig);
2152
+ if (!target.entityType || !target.rowId) continue;
2153
+ const key = annotationKey(target, cConfig.data);
2154
+ if (seen.has(key)) continue;
2155
+ seen.add(key);
2156
+ cells.push({ entityType: target.entityType, rowId: target.rowId, columnKey: cConfig.data });
2157
+ }
2158
+ }
2159
+ return cells;
2160
+ },
2161
+ [store, cols, resolveCommentTarget],
2162
+ );
2163
+
2164
+ // Resolve a comment-indicator (or any element inside a cell) to its cell
2165
+ // coordinates + the annotation backing it. Used by the hover preview and the
2166
+ // click-to-open-dialog paths.
2167
+ const resolveCommentCell = useCallback((el: Element) => {
2168
+ const cell = el.closest('td[data-row][data-col]') as HTMLElement | null;
2169
+ if (!cell) return null;
2170
+ const rowIndex = parseInt(cell.getAttribute('data-row') || '', 10);
2171
+ const colIndex = parseInt(cell.getAttribute('data-col') || '', 10);
2172
+ if (Number.isNaN(rowIndex) || Number.isNaN(colIndex)) return null;
2173
+ const rowData = store.getRowData(rowIndex);
2174
+ const col = cols[colIndex];
2175
+ if (!rowData || !col) return null;
2176
+ // B8a — the target comes from the CELL. `rowId` is the target's id, which
2177
+ // on a leg-scoped column is the leg and on a container-scoped column the
2178
+ // container, NOT the row's id column.
2179
+ const target = resolveCommentTarget(rowData, col);
2180
+ if (!target.rowId || !target.entityType) return null;
2181
+ return {
2182
+ rowIndex,
2183
+ colIndex,
2184
+ rowData,
2185
+ col,
2186
+ entityType: target.entityType,
2187
+ rowId: target.rowId,
2188
+ annotation: annotations.get(annotationKey(target, col.data)),
2189
+ cell,
2190
+ };
2191
+ }, [store, cols, resolveCommentTarget, annotations]);
2192
+
2193
+ // Track the cell under the pointer so it can offer the comment affordance
2194
+ // (ledger 8.1 — hover used to paint a selection box and nothing else).
2195
+ //
2196
+ // Written to the STORE, never to React state: `setHoveredCell` repaints the
2197
+ // cell left and the cell entered and nothing else, while a `useState` here
2198
+ // would re-render every mounted row on every pointer move. Piggy-backs on the
2199
+ // container's existing delegated mouseover, so no new listener is added.
2200
+ const handleCellHoverOver = useCallback((e: React.MouseEvent) => {
2201
+ if (!enableComments || !commentsEntityType) return;
2202
+ const td = (e.target as HTMLElement).closest?.('td.hot-cell[data-row][data-col]') as HTMLElement | null;
2203
+ if (!td || td.hasAttribute('data-actions-cell')) { store.setHoveredCell(null); return; }
2204
+ const row = Number(td.getAttribute('data-row'));
2205
+ const col = Number(td.getAttribute('data-col'));
2206
+ if (Number.isNaN(row) || Number.isNaN(col)) { store.setHoveredCell(null); return; }
2207
+ store.setHoveredCell({ row, col });
2208
+ }, [enableComments, commentsEntityType, store]);
2209
+
2210
+ // Hover the comment indicator → show a read-only preview of its thread.
2211
+ const handleCommentHoverOver = useCallback((e: React.MouseEvent) => {
2212
+ if (!enableComments || !commentsEntityType) return;
2213
+ const indicator = (e.target as HTMLElement).closest?.('.cell-comment-indicator');
2214
+ if (!indicator) return;
2215
+ const info = resolveCommentCell(indicator);
2216
+ if (!info?.annotation || info.annotation.comments.length === 0) return;
2217
+ setCommentHover({
2218
+ comments: info.annotation.comments,
2219
+ columnTitle: info.col.title || info.col.data,
2220
+ anchorRect: indicator.getBoundingClientRect(),
2221
+ });
2222
+ }, [enableComments, commentsEntityType, resolveCommentCell]);
2223
+
2224
+ const handleCommentHoverOut = useCallback((e: React.MouseEvent) => {
2225
+ const indicator = (e.target as HTMLElement).closest?.('.cell-comment-indicator');
2226
+ if (!indicator) return;
2227
+ // mouseout bubbles from the icon's children — only close when truly leaving.
2228
+ const related = e.relatedTarget as Node | null;
2229
+ if (related && indicator.contains(related)) return;
2230
+ setCommentHover(null);
2231
+ }, []);
2232
+
2233
+ // Open comment dialog on Shift+Click (or a direct click on the comment icon)
2234
+ // when comments are enabled.
2235
+ // Called from onMouseDown so it runs BEFORE handleMouseDown resets the selection.
2236
+ // Returns true if it handled the event (caller should skip normal mousedown).
2237
+ const handleCommentMouseDown = useCallback((e: React.MouseEvent): boolean => {
2238
+ if (!enableComments || !commentsEntityType) return false;
2239
+ // Direct click on the comment indicator opens the full thread dialog — no
2240
+ // modifier required (the icon is the affordance).
2241
+ const indicator = (e.target as HTMLElement).closest?.('.cell-comment-indicator');
2242
+ if (indicator) {
2243
+ const info = resolveCommentCell(indicator);
2244
+ if (info) {
2245
+ e.preventDefault();
2246
+ e.stopPropagation();
2247
+ setCommentHover(null);
2248
+ setCommentDialog({
2249
+ entityType: info.entityType,
2250
+ rowId: info.rowId,
2251
+ columnKey: info.col.data,
2252
+ columnTitle: info.col.title || info.col.data,
2253
+ rowLabel: info.rowData.name || info.rowData.title || undefined,
2254
+ annotationId: info.annotation?.id || null,
2255
+ currentColor: info.annotation?.color || null,
2256
+ anchorRect: (info.cell as HTMLElement).getBoundingClientRect(),
2257
+ });
2258
+ return true;
2259
+ }
2260
+ }
2261
+ if (!e.shiftKey) return false;
2262
+
2263
+ const target = e.target as HTMLElement;
2264
+ const cell = target.closest('td[data-row][data-col]');
2265
+ if (!cell) return false;
2266
+
2267
+ const rowIndex = parseInt(cell.getAttribute('data-row') || '', 10);
2268
+ const colIndex = parseInt(cell.getAttribute('data-col') || '', 10);
2269
+ if (isNaN(rowIndex) || isNaN(colIndex)) return false;
2270
+
2271
+ const rowData = store.getRowData(rowIndex);
2272
+ const colConfig = cols[colIndex];
2273
+ if (!rowData || !colConfig) return false;
2274
+
2275
+ const cellTarget = resolveCommentTarget(rowData, colConfig);
2276
+ if (!cellTarget.entityType || !cellTarget.rowId) return false;
2277
+
2278
+ // Check if there's a multi-cell selection — must read BEFORE handleMouseDown resets it
2279
+ const bounds = store.getSelectionBounds();
2280
+ const isMultiCell = bounds && (bounds.startRow !== bounds.endRow || bounds.startCol !== bounds.endCol);
2281
+
2282
+ if (isMultiCell && bounds) {
2283
+ const bulkCells = collectBulkCells(bounds);
2284
+ if (bulkCells.length > 1) {
2285
+ e.preventDefault();
2286
+ e.stopPropagation();
2287
+ setCommentDialog({
2288
+ entityType: cellTarget.entityType,
2289
+ rowId: cellTarget.rowId,
2290
+ columnKey: colConfig.data,
2291
+ columnTitle: colConfig.title || colConfig.data,
2292
+ anchorRect: (cell as HTMLElement).getBoundingClientRect(),
2293
+ bulkCells,
2294
+ });
2295
+ return true;
2296
+ }
2297
+ }
2298
+
2299
+ // Single cell — open comment dialog (also prevent mousedown from resetting)
2300
+ e.preventDefault();
2301
+ e.stopPropagation();
2302
+
2303
+ const annotation = annotations.get(annotationKey(cellTarget, colConfig.data));
2304
+
2305
+ setCommentDialog({
2306
+ entityType: cellTarget.entityType,
2307
+ rowId: cellTarget.rowId,
2308
+ columnKey: colConfig.data,
2309
+ columnTitle: colConfig.title || colConfig.data,
2310
+ rowLabel: rowData.name || rowData.title || undefined,
2311
+ annotationId: annotation?.id || null,
2312
+ currentColor: annotation?.color || null,
2313
+ anchorRect: (cell as HTMLElement).getBoundingClientRect(),
2314
+ });
2315
+ return true;
2316
+ }, [enableComments, commentsEntityType, store, cols, resolveCommentTarget, collectBulkCells, annotations]);
2317
+
2318
+ // ── Cell-range selection: committed dotted outline + drag-end context menu ──
2319
+ // The dotted outline and the Copy/Comment menu appear ONLY when a drag finishes
2320
+ // on a multi-cell range. `rangePhase` drives the `data-range-phase` attribute
2321
+ // the CSS keys off of: 'committed' (fade in) → 'exiting' (fade out) → cleared.
2322
+ const [rangePhase, setRangePhase] = useState<'idle' | 'committed' | 'exiting'>('idle');
2323
+ const rangePhaseRef = useRef<'idle' | 'committed' | 'exiting'>('idle');
2324
+ const rangeExitTimerRef = useRef<number | null>(null);
2325
+ const applyRangePhase = useCallback((p: 'idle' | 'committed' | 'exiting') => {
2326
+ rangePhaseRef.current = p;
2327
+ setRangePhase(p);
2328
+ }, []);
2329
+
2330
+ // Clear the selection, fading a committed outline out first (opacity 1→0).
2331
+ const clearSelectionAnimated = useCallback(() => {
2332
+ if (rangePhaseRef.current === 'committed') {
2333
+ applyRangePhase('exiting');
2334
+ if (rangeExitTimerRef.current) window.clearTimeout(rangeExitTimerRef.current);
2335
+ rangeExitTimerRef.current = window.setTimeout(() => {
2336
+ store.setSelection({ type: null, anchor: null, focus: null });
2337
+ applyRangePhase('idle');
2338
+ rangeExitTimerRef.current = null;
2339
+ }, 170);
2340
+ } else {
2341
+ store.setSelection({ type: null, anchor: null, focus: null });
2342
+ applyRangePhase('idle');
2343
+ }
2344
+ }, [store, applyRangePhase]);
2345
+
2346
+ const copySelectionToClipboard = useCallback(() => {
2347
+ const cells = store.getCellsInSelection();
2348
+ const bounds = store.getSelectionBounds();
2349
+ if (!cells.length || !bounds) return;
2350
+ const text = selectionToTsv(cells, bounds);
2351
+ // Marching ants go up IMMEDIATELY — Excel's marquee appears with the
2352
+ // gesture, not a round-trip later, and `navigator.clipboard.writeText` is
2353
+ // async. They are taken back down in the failure branch, so an outline
2354
+ // claiming the clipboard holds this block never outlives a rejected write.
2355
+ store.setClipboardMarker({ mode: 'copy', bounds });
2356
+ void navigator.clipboard?.writeText(text)
2357
+ .then(() => flash(t('dynamicTable.cellMenu.copied', 'Copied to clipboard'), 'success'))
2358
+ .catch(() => {
2359
+ store.setClipboardMarker(null);
2360
+ flash(t('dynamicTable.cellMenu.copyFailed', 'Could not copy to clipboard'), 'error');
2361
+ });
2362
+ }, [store, t]);
2363
+
2364
+ // Ctrl/Cmd+C copy. The base handler copies a cell-range selection; when there's
2365
+ // no range but rows are checkbox-selected, fall back to copying those rows
2366
+ // (visible columns, in page order) as TSV. Either way, flash success.
2367
+ const copyRowSelectionText = useCallback((): string | null => {
2368
+ if (selectedRowIds.size === 0) return null;
2369
+ const rowCount = store.getRowCount();
2370
+ const lines: string[] = [];
2371
+ for (let r = 0; r < rowCount; r++) {
2372
+ const rowData = store.getRowData(r);
2373
+ if (!rowData) continue;
2374
+ const id = String(rowData[selectionColumnName] ?? '');
2375
+ if (!id || !selectedRowIds.has(id)) continue;
2376
+ lines.push(cols.map((_, c) => String(store.getCellValue(r, c) ?? '')).join('\t'));
2377
+ }
2378
+ return lines.length ? lines.join('\n') : null;
2379
+ }, [store, selectedRowIds, selectionColumnName, cols]);
2380
+
2381
+ const handleCopied = useCallback((bounds: SelectionBounds | null) => {
2382
+ // Excel's marquee. `bounds` is null for the checkbox-row fallback, which
2383
+ // has no rectangle to draw around — that path copies rows, not a range.
2384
+ if (bounds) store.setClipboardMarker({ mode: 'copy', bounds });
2385
+ flash(t('dynamicTable.cellMenu.copied', 'Copied to clipboard'), 'success');
2386
+ }, [store, t]);
2387
+
2388
+ const handleCopy = useCopyHandler(store, tableRef, {
2389
+ getFallbackText: copyRowSelectionText,
2390
+ onCopy: handleCopied,
2391
+ });
2392
+
2393
+ // Grouped-actions-bar Copy button: actively write the selected rows to the
2394
+ // clipboard (the Ctrl+C path relies on the browser copy event; a button click
2395
+ // can't, so it uses the async clipboard API directly). Flashes on success.
2396
+ const handleBulkCopy = useCallback(() => {
2397
+ const text = copyRowSelectionText();
2398
+ if (!text) return;
2399
+ void navigator.clipboard?.writeText(text)
2400
+ .then(() => flash(t('dynamicTable.cellMenu.copied', 'Copied to clipboard'), 'success'))
2401
+ .catch(() => flash(t('dynamicTable.cellMenu.copyFailed', 'Could not copy to clipboard'), 'error'));
2402
+ }, [copyRowSelectionText, t]);
2403
+
2404
+ // Shared serializer: turn an extracted table into a downloaded CSV/Excel file.
2405
+ // The xlsx serializer is imported lazily so the CSV path never loads SheetJS.
2406
+ const serializeAndDownload = useCallback(async (table: ExtractedTable, format: ExportFormat) => {
2407
+ if (table.rows.length === 0) {
2408
+ flash(t('dynamicTable.export.empty', 'No rows to export'), 'info');
2409
+ return;
2410
+ }
2411
+ const stem = exportFileName?.trim() || slugifyFileName(displayTableName);
2412
+ try {
2413
+ if (format === 'csv') {
2414
+ const blob = new Blob([toCsv(table)], { type: 'text/csv;charset=utf-8' });
2415
+ downloadBlob(blob, `${stem}.csv`);
2416
+ } else {
2417
+ const XLSX = await import('xlsx');
2418
+ downloadBlob(toXlsxBlob(table, XLSX), `${stem}.xlsx`);
2419
+ }
2420
+ flash(t('dynamicTable.export.success', 'Export ready'), 'success');
2421
+ } catch {
2422
+ flash(t('dynamicTable.export.failed', 'Could not export'), 'error');
2423
+ }
2424
+ }, [exportFileName, displayTableName, t]);
2425
+
2426
+ // Grouped-actions-bar export: the checkbox-selected rows, visible columns in
2427
+ // view order. Mirrors the Copy extraction (`store.getCellValue`).
2428
+ const handleExportSelection = useCallback((format: ExportFormat) => {
2429
+ void serializeAndDownload(
2430
+ extractExportRows(store, cols, selectedRowIds, selectionColumnName),
2431
+ format,
2432
+ );
2433
+ }, [serializeAndDownload, store, cols, selectedRowIds, selectionColumnName]);
2434
+
2435
+ // Toolbar export: the WHOLE table — every column (ignoring the active
2436
+ // perspective's column visibility) and every row. When the page supplies
2437
+ // `onExportAll`, re-fetch all pages from the server (ignoring filters);
2438
+ // otherwise fall back to the currently-loaded rows.
2439
+ const [isExportingAll, setIsExportingAll] = useState(false);
2440
+ const handleExportAll = useCallback(async (format: ExportFormat) => {
2441
+ if (isExportingAll) return;
2442
+ setIsExportingAll(true);
2443
+ try {
2444
+ const outcome = onExportAll ? await onExportAll() : data;
2445
+
2446
+ // HEDGE-123 — a file is only written for a result proven complete.
2447
+ //
2448
+ // `onExportAll` may reject (a page failed, or came back unreadable) — the
2449
+ // catch below turns that into a visible error and NO download, which is
2450
+ // the point: a failure the user can see and retry, instead of a short
2451
+ // file they cannot. It may also resolve carrying its own counts, for the
2452
+ // case where rows were fetched but completeness could not be proven.
2453
+ // A bare array still means complete, so hosts predating this are unchanged.
2454
+ const counted: ExportAllResult | null =
2455
+ outcome != null && !Array.isArray(outcome) ? (outcome as ExportAllResult) : null;
2456
+ const rows = counted ? counted.rows : (outcome as any[]);
2457
+
2458
+ if (counted && !counted.complete) {
2459
+ // Deliberately NOT a download. Handing over a file here would restore
2460
+ // exactly the defect this replaces — the numbers exist precisely so the
2461
+ // user is told what is missing rather than shown a plausible file.
2462
+ flash(
2463
+ t(
2464
+ 'dynamicTable.export.incomplete',
2465
+ 'Export stopped early and was NOT saved — {reason}. Try again.',
2466
+ { reason: counted.reason ?? 'the full table could not be read' },
2467
+ ),
2468
+ 'error',
2469
+ );
2470
+ return;
2471
+ }
2472
+
2473
+ await serializeAndDownload(extractExportRowsFromData(rows ?? [], baseColumns), format);
2474
+ } catch (err) {
2475
+ // Name the failure. "Could not export" alone left a user unable to say
2476
+ // whether it was their filter, the network, or the server.
2477
+ const detail = err instanceof Error && err.message ? err.message : null;
2478
+ flash(
2479
+ detail
2480
+ ? t('dynamicTable.export.failedDetail', 'Could not export — {detail}', { detail })
2481
+ : t('dynamicTable.export.failed', 'Could not export'),
2482
+ 'error',
2483
+ );
2484
+ } finally {
2485
+ setIsExportingAll(false);
2486
+ }
2487
+ }, [isExportingAll, onExportAll, data, serializeAndDownload, baseColumns, t]);
2488
+
2489
+ const openAnnotateForSelection = useCallback(() => {
2490
+ if (!enableComments || !commentsEntityType) return;
2491
+ const bounds = store.getSelectionBounds();
2492
+ if (!bounds) return;
2493
+ const anchorCell = tableRef.current?.querySelector(
2494
+ `td[data-row="${bounds.endRow}"][data-col="${bounds.endCol}"]`
2495
+ ) as HTMLElement | null;
2496
+ // MODEL FALLBACK. Under column virtualization the range's bottom-right cell
2497
+ // may not be mounted, and an `undefined` rect drops the dialog into the
2498
+ // middle of the viewport instead of next to the selection. Column x comes
2499
+ // from the prefix-sum geometry (which answers for unmounted columns), row y
2500
+ // from the row height — the two facts the DOM was being asked for.
2501
+ const anchorRect = anchorCell?.getBoundingClientRect() ?? (() => {
2502
+ const el = tableRef.current;
2503
+ if (!el) return undefined;
2504
+ const box = el.getBoundingClientRect();
2505
+ const geo = columnVirtualizer.geometry;
2506
+ const x = box.left + (geo.starts[bounds.endCol] ?? 0) - el.scrollLeft;
2507
+ const y = box.top + bounds.endRow * dataRowHeight - el.scrollTop;
2508
+ const width = store.getColumnWidth(bounds.endCol);
2509
+ return new DOMRect(x, y, width, dataRowHeight);
2510
+ })();
2511
+ const firstRowData = store.getRowData(bounds.startRow);
2512
+ const firstCol = cols[bounds.startCol];
2513
+ if (!firstRowData || !firstCol) return;
2514
+ const firstTarget = resolveCommentTarget(firstRowData, firstCol);
2515
+ if (!firstTarget.entityType || !firstTarget.rowId) return;
2516
+
2517
+ const isMultiCell = bounds.startRow !== bounds.endRow || bounds.startCol !== bounds.endCol;
2518
+ if (isMultiCell) {
2519
+ const bulkCells = collectBulkCells(bounds);
2520
+ if (bulkCells.length > 1) {
2521
+ setCommentDialog({
2522
+ entityType: firstTarget.entityType,
2523
+ rowId: firstTarget.rowId,
2524
+ columnKey: firstCol.data,
2525
+ columnTitle: firstCol.title || firstCol.data,
2526
+ anchorRect,
2527
+ bulkCells,
2528
+ });
2529
+ return;
2530
+ }
2531
+ }
2532
+ const annotation = annotations.get(annotationKey(firstTarget, firstCol.data));
2533
+ setCommentDialog({
2534
+ entityType: firstTarget.entityType,
2535
+ rowId: firstTarget.rowId,
2536
+ columnKey: firstCol.data,
2537
+ columnTitle: firstCol.title || firstCol.data,
2538
+ rowLabel: firstRowData.name || firstRowData.title || undefined,
2539
+ annotationId: annotation?.id || null,
2540
+ currentColor: annotation?.color || null,
2541
+ anchorRect,
2542
+ });
2543
+ }, [enableComments, commentsEntityType, store, cols, resolveCommentTarget, collectBulkCells, annotations, columnVirtualizer, dataRowHeight]);
2544
+
2545
+ // After a drag finishes on a multi-cell range, commit the outline.
2546
+ //
2547
+ // This used to ALSO pop the cell context menu at the cursor, unprompted, on
2548
+ // every range drag — so selecting cells to read them threw a menu over the
2549
+ // data you had just selected. Copy is `Ctrl/⌘+C` (and the bulk bar, and
2550
+ // right-click), so the popup was interrupting to offer something the
2551
+ // keyboard already does. Right-click still opens the same menu, which is
2552
+ // where Comment / colour remains discoverable.
2553
+ const handleMouseUpWithMenu = useCallback(() => {
2554
+ handleMouseUp();
2555
+ const bounds = store.getSelectionBounds();
2556
+ const isRange = !!bounds && (bounds.startRow !== bounds.endRow || bounds.startCol !== bounds.endCol);
2557
+ if (isRange) applyRangePhase('committed');
2558
+ }, [handleMouseUp, store, applyRangePhase]);
2559
+
2560
+ // ---- Fill handle (drag-to-fill) ----
2561
+ // A fill drag starts on the bottom-right nub of the SELECTION — any size
2562
+ // (`[data-fill-handle]`). Excel fills a RECTANGLE in four directions and each
2563
+ // source line continues its own series; so does this. Distinct from
2564
+ // range-select, which starts on the cell body. Opt-in via
2565
+ // `uiConfig.enableFillHandle`.
2566
+ //
2567
+ // The geometry (axis lock, back-drag, direction) is NOT computed here — it
2568
+ // lives in `utils/fillGeometry.ts` as a pure function, because rules a user
2569
+ // notices must be provable without a browser.
2570
+ const fillStateRef = useRef<{
2571
+ source: SelectionBounds;
2572
+ pointer: { row: number; col: number };
2573
+ modifier: boolean;
2574
+ } | null>(null);
2575
+ // A large write held back for confirmation. Deliberately a WRITE BATCH, not a
2576
+ // fill-shaped triple: the same dialog now guards a big paste, and a second
2577
+ // confirmation flow is how the two would drift apart.
2578
+ const [pendingWrite, setPendingWrite] = useState<{
2579
+ writes: CellWrite[];
2580
+ origin: CellWriteOrigin;
2581
+ /**
2582
+ * Carried through the dialog rather than re-derived from `origin`: a
2583
+ * back-drag clear IS the fill handle and must honour `disableFill`, while a
2584
+ * Delete-key clear is an ordinary edit and must not. Same origin, different
2585
+ * answer — so the answer travels with the batch.
2586
+ */
2587
+ respectDisableFill?: boolean;
2588
+ } | null>(null);
2589
+
2590
+ /**
2591
+ * Seed values for one fill line, read by INDEX into the full column array.
2592
+ * Never by DOM query: a column may be unmounted by column virtualization and
2593
+ * still be part of the source block.
2594
+ */
2595
+ const readFillSeed = useCallback(
2596
+ (cells: Array<{ row: number; col: number }>, seedColIndex: number): FillSeed => {
2597
+ const column = cols[seedColIndex];
2598
+ const values = cells.map(({ row, col }) => {
2599
+ const raw = store.getCellValue(row, col);
2600
+ // A boolean column may be backed by a non-boolean value (a timestamp
2601
+ // rendered as a checkbox). Seed the boolean STATE so the per-cell save
2602
+ // coerces and persists exactly like a manual toggle would.
2603
+ return cols[col]?.type === 'boolean' ? Boolean(raw) : raw;
2604
+ });
2605
+ return { values, column };
2606
+ },
2607
+ [cols, store],
2608
+ );
2609
+
2610
+ /** Re-derive the preview rectangle for the current pointer and modifier. */
2611
+ const paintFillPreview = useCallback(() => {
2612
+ const fs = fillStateRef.current;
2613
+ if (!fs) return;
2614
+ // Two passes, deliberately: the axis decides which line is the seed, and the
2615
+ // seed decides the default mode. Pass 1 is geometry only, so the mode it is
2616
+ // handed is discarded; pass 2 re-labels it with the truth.
2617
+ const geometry = computeFillPreview(fs.source, fs.pointer, {
2618
+ modifier: false,
2619
+ defaultMode: 'series',
2620
+ });
2621
+ const [firstLine] = buildFillLines(geometry);
2622
+ let defaultMode: FillMode = 'copy';
2623
+ if (firstLine && firstLine.seedCells.length > 0) {
2624
+ defaultMode = detectFillPattern(readFillSeed(firstLine.seedCells, firstLine.seedCol)).defaultMode;
2625
+ }
2626
+ const mode = fs.modifier ? invertFillMode(defaultMode) : defaultMode;
2627
+ store.setFillPreview({ ...geometry, mode });
2628
+ }, [store, readFillSeed]);
2629
+
2630
+ const handleFillMouseDown = useCallback((e: React.MouseEvent): boolean => {
2631
+ if (!enableFillHandle) return false;
2632
+ if (!(e.target as HTMLElement).closest('[data-fill-handle]')) return false;
2633
+ const bounds = store.getSelectionBounds();
2634
+ if (!bounds) return false;
2635
+ // Selection SIZE is unconstrained — an N×M block is a legitimate source.
2636
+ // Fillability is a property of the block, not of one corner cell: a source
2637
+ // containing a read-only column still fills, that column's writes are just
2638
+ // rejected and reported. This mirrors the store's `isFillOrigin`, which is
2639
+ // what put the nub on screen in the first place.
2640
+ const anyFillable = (() => {
2641
+ for (let c = bounds.startCol; c <= bounds.endCol; c++) {
2642
+ const col = cols[c];
2643
+ if (col && !col.readOnly && !col.disableFill) return true;
2644
+ }
2645
+ return false;
2646
+ })();
2647
+ if (!anyFillable) return false;
2648
+ fillStateRef.current = {
2649
+ source: bounds,
2650
+ pointer: { row: bounds.endRow, col: bounds.endCol },
2651
+ modifier: e.altKey || e.ctrlKey,
2652
+ };
2653
+ paintFillPreview();
2654
+ e.preventDefault();
2655
+ return true;
2656
+ }, [enableFillHandle, store, cols, paintFillPreview]);
2657
+
2658
+ const handleFillMouseMove = useCallback((e: React.MouseEvent) => {
2659
+ const fs = fillStateRef.current;
2660
+ if (!fs) return;
2661
+ const cell = document.elementFromPoint(e.clientX, e.clientY)?.closest('td');
2662
+ if (!cell) return;
2663
+ // A 2D drag crosses the row-header gutter and the actions column, neither of
2664
+ // which is a data cell. Reading `data-col` off them yields NaN or an index
2665
+ // outside the column array, so they are rejected outright — the preview
2666
+ // simply stops updating rather than jumping to column 0.
2667
+ if (cell.hasAttribute('data-row-header') || cell.hasAttribute('data-actions-cell')) return;
2668
+ const row = parseInt(cell.getAttribute('data-row') || '', 10);
2669
+ const col = parseInt(cell.getAttribute('data-col') || '', 10);
2670
+ if (Number.isNaN(row) || Number.isNaN(col)) return;
2671
+ if (col < 0 || col >= cols.length) return;
2672
+ fs.pointer = { row, col };
2673
+ // Excel lets the modifier be pressed or released MID-drag, so it is tracked
2674
+ // continuously and the preview badge follows.
2675
+ fs.modifier = e.altKey || e.ctrlKey;
2676
+ paintFillPreview();
2677
+ }, [cols, paintFillPreview]);
2678
+
2679
+ /**
2680
+ * THE multi-cell write. Fill, paste, cut, clear, find-&-replace and import all
2681
+ * come through here — a second write path is how the grid ends up with two
2682
+ * undo behaviours and two rejection tables.
2683
+ *
2684
+ * No `beginUndoGroup()` here: `applyCellWrites` already opens exactly one
2685
+ * group around the whole batch, and a second group NESTS and destroys the
2686
+ * outer one, which would turn a 30x4 paste back into 120 separate Ctrl+Z's.
2687
+ *
2688
+ * No caller-side `disableFill` filtering either: `applyCellWrites` rejects it
2689
+ * when (and only when) `origin === 'fill'`. A paste or a replace MUST still
2690
+ * write those columns — `disableFill` excludes identity fields from the DRAG
2691
+ * HANDLE, it does not make them read-only.
2692
+ *
2693
+ * `tableRef.current` is read INSIDE the callback so the batch events reach the
2694
+ * live container even if it remounted (fullscreen re-parents it).
2695
+ */
2696
+ /**
2697
+ * Bumped after every multi-cell write. `CellStore` bumps revisions PER CELL,
2698
+ * so nothing at this level can observe a batch landing — and find-and-replace
2699
+ * has to re-scan after its own Replace All, or it goes on reporting
2700
+ * counts for text it just changed. One counter covers every write path
2701
+ * (paste, cut, clear, fill, replace, and the large-write dialog).
2702
+ */
2703
+ const [writeRevision, setWriteRevision] = useState(0);
2704
+
2705
+ const writeCells = useCallback(
2706
+ (
2707
+ writes: readonly CellWrite[],
2708
+ origin: CellWriteOrigin,
2709
+ respectDisableFill?: boolean,
2710
+ ): CellWriteReport => {
2711
+ const report = applyCellWrites(
2712
+ {
2713
+ store,
2714
+ columns: cols,
2715
+ handleCellSave,
2716
+ coerce: coerceCellValue,
2717
+ element: tableRef.current,
2718
+ idColumnName,
2719
+ },
2720
+ writes,
2721
+ { origin, respectDisableFill },
2722
+ );
2723
+ if (report.written > 0) setWriteRevision((n) => n + 1);
2724
+ return report;
2725
+ },
2726
+ [store, cols, handleCellSave, idColumnName, tableRef],
2727
+ );
2728
+
2729
+ /**
2730
+ * Say what happened. A batch that silently drops 12 of 40 cells is the single
2731
+ * worst failure mode of a paste, so rejections are always surfaced — counted
2732
+ * by reason, never as a generic "some cells failed".
2733
+ */
2734
+ const reportWrite = useCallback((report: CellWriteReport) => {
2735
+ if (report.rejected.length === 0) {
2736
+ if (report.written > 0) {
2737
+ // Name the gesture back to the user: "Cleared 12 cells" after Delete
2738
+ // reads as confirmation, "Updated 12 cells" reads as a surprise.
2739
+ const applied = report.origin === 'cut'
2740
+ ? t('dynamicTable.cut.done', 'Cut {count} cells', { count: report.written })
2741
+ : report.origin === 'clear'
2742
+ ? t('dynamicTable.clear.done', 'Cleared {count} cells', { count: report.written })
2743
+ : t('dynamicTable.write.applied', 'Updated {count} cells', { count: report.written });
2744
+ flash(applied, 'success');
2745
+ }
2746
+ return;
2747
+ }
2748
+ const parts: string[] = [];
2749
+ const r = report.rejectedByReason;
2750
+ if (r.readOnly) parts.push(t('dynamicTable.write.rejectedReadOnly', '{count} read-only', { count: r.readOnly }));
2751
+ if (r.notCoercible) parts.push(t('dynamicTable.write.rejectedNotCoercible', '{count} wrong type', { count: r.notCoercible }));
2752
+ if (r.notInSource) parts.push(t('dynamicTable.write.rejectedNotInSource', '{count} not an allowed value', { count: r.notInSource }));
2753
+ if (r.required) parts.push(t('dynamicTable.write.rejectedRequired', '{count} required', { count: r.required }));
2754
+ if (r.outOfRange) parts.push(t('dynamicTable.write.rejectedOutOfRange', '{count} outside the table', { count: r.outOfRange }));
2755
+ flash(
2756
+ t('dynamicTable.write.partial', 'Updated {written} cells; skipped {skipped} ({reasons})', {
2757
+ written: report.written,
2758
+ skipped: report.rejected.length,
2759
+ reasons: parts.join(', '),
2760
+ }),
2761
+ report.written > 0 ? 'info' : 'error',
2762
+ );
2763
+ }, [t]);
2764
+
2765
+ /** Run a batch now, or hold it for confirmation when it is large. */
2766
+ const submitWrites = useCallback((writes: CellWrite[], origin: CellWriteOrigin) => {
2767
+ if (writes.length === 0) return;
2768
+ // The threshold counts CELLS (rows x columns), not rows — a 40-row paste
2769
+ // across 6 columns is 240 persist requests, and that is what the user is
2770
+ // being asked about.
2771
+ if (writes.length > fillConfirmThreshold) {
2772
+ setPendingWrite({ writes, origin });
2773
+ return;
2774
+ }
2775
+ reportWrite(writeCells(writes, origin));
2776
+ }, [fillConfirmThreshold, reportWrite, writeCells]);
2777
+
2778
+ /**
2779
+ * Turn the preview rectangle into writes and hand them to the ONE write
2780
+ * primitive.
2781
+ *
2782
+ * The undo rule this must not break: `applyCellWrites` opens exactly ONE
2783
+ * group around the whole batch. The per-line loop below GENERATES VALUES, it
2784
+ * does not write — iterating lines and calling the writer once per line would
2785
+ * produce one undo entry per column, so an n-column fill would need n Ctrl+Z's.
2786
+ */
2787
+ const commitFill = useCallback(() => {
2788
+ const fs = fillStateRef.current;
2789
+ if (!fs) return;
2790
+ fillStateRef.current = null;
2791
+
2792
+ const preview = store.getFillPreview();
2793
+ if (!preview) { store.setFillPreview(null); return; }
2794
+
2795
+ // Back-drag: the cells the user dragged back over are EMPTIED. Not a special
2796
+ // path — same primitive, same single undo entry, same persistence.
2797
+ if (preview.clearing && preview.cleared) {
2798
+ const writes: CellWrite[] = cellsInRect(preview.cleared).map(({ row, col }) => ({
2799
+ row,
2800
+ col,
2801
+ value: null,
2802
+ }));
2803
+ if (writes.length === 0) { store.setFillPreview(null); return; }
2804
+ if (writes.length > fillConfirmThreshold) {
2805
+ setPendingWrite({ writes, origin: 'clear', respectDisableFill: true });
2806
+ return;
2807
+ }
2808
+ reportWrite(writeCells(writes, 'clear', true));
2809
+ store.setFillPreview(null);
2810
+ return;
2811
+ }
2812
+
2813
+ // Each source LINE continues its own series: one line per source column on a
2814
+ // vertical fill, one per source row on a horizontal one. That is Excel's
2815
+ // rule and it is what stops a multi-column drag smearing one column's
2816
+ // sequence sideways.
2817
+ const writes: CellWrite[] = [];
2818
+ for (const line of buildFillLines(preview)) {
2819
+ if (line.targets.length === 0) continue;
2820
+ const seed = readFillSeed(line.seedCells, line.seedCol);
2821
+ const pattern = detectFillPattern(seed);
2822
+ const mode = fs.modifier ? invertFillMode(pattern.defaultMode) : pattern.defaultMode;
2823
+ const values = generateFill(seed, line.targets.length, preview.backwards ? -1 : 1, mode);
2824
+ line.targets.forEach((cell, i) => {
2825
+ writes.push({ row: cell.row, col: cell.col, value: values[i] });
2826
+ });
2827
+ }
2828
+
2829
+ if (writes.length === 0) { store.setFillPreview(null); return; }
2830
+ if (writes.length > fillConfirmThreshold) {
2831
+ // Keep the dashed preview up while the user confirms the large write.
2832
+ setPendingWrite({ writes, origin: 'fill' });
2833
+ return;
2834
+ }
2835
+ reportWrite(writeCells(writes, 'fill'));
2836
+ store.setFillPreview(null);
2837
+ }, [store, fillConfirmThreshold, writeCells, reportWrite, readFillSeed]);
2838
+
2839
+ const confirmPendingWrite = useCallback(() => {
2840
+ if (pendingWrite) {
2841
+ reportWrite(
2842
+ writeCells(pendingWrite.writes, pendingWrite.origin, pendingWrite.respectDisableFill),
2843
+ );
2844
+ }
2845
+ setPendingWrite(null);
2846
+ store.setFillPreview(null);
2847
+ }, [pendingWrite, writeCells, reportWrite, store]);
2848
+
2849
+ const cancelPendingWrite = useCallback(() => {
2850
+ setPendingWrite(null);
2851
+ store.setFillPreview(null);
2852
+ }, [store]);
2853
+
2854
+ // ---- In-grid Find & Replace ----
2855
+ /**
2856
+ * Bring a match into view under virtualization.
2857
+ *
2858
+ * Three things have to happen in order, and skipping any one of them lands the
2859
+ * user on the wrong cell:
2860
+ * 1. a match inside a COLLAPSED GROUP expands that group first —
2861
+ * `dataIndexToVisualIndex` silently returns the raw data index for a row
2862
+ * that is not visible, which would scroll to an unrelated row;
2863
+ * 2. the ROW is scrolled by the virtualizer, which is the only thing that
2864
+ * knows where an unmounted row lives;
2865
+ * 3. the COLUMN is scrolled through the column virtualizer's `scrollToColumn`,
2866
+ * which answers from the PREFIX-SUM GEOMETRY — not from a `<td>`.
2867
+ *
2868
+ * (3) used to be `querySelector('td[data-row][data-col]')?.scrollIntoView()`
2869
+ * in a `requestAnimationFrame`. That is an indices→element read, which the
2870
+ * range-operation contract forbids for exactly the reason it failed here: a
2871
+ * match in a column outside the mounted window has no `<td>`, so the lookup
2872
+ * returned null and the bar made a cell active at x=1179 in a 966px
2873
+ * viewport with `scrollLeft` still 0 (ledger row 1.24). `scrollToColumn` is
2874
+ * model-driven and therefore answers for unmounted columns, and it runs
2875
+ * SYNCHRONOUSLY — mounting the column is a consequence of the scroll, not a
2876
+ * precondition for it.
2877
+ */
2878
+ const scrollToCell = useCallback((row: number, col: number) => {
2879
+ const groupKey = groupingResult.groupKeyOfDataIndex(row);
2880
+ if (groupKey && groupingResult.collapsedGroups.has(groupKey)) {
2881
+ groupingResult.toggleGroup(groupKey);
2882
+ }
2883
+ const visualIndex = groupingResult.visualRows
2884
+ ? groupingResult.dataIndexToVisualIndex(row)
2885
+ : row;
2886
+ rowVirtualizer.scrollToIndex(visualIndex, { align: 'center' });
2887
+ columnVirtualizerRef.current.scrollToColumn(col, { align: 'auto' });
2888
+ }, [groupingResult, rowVirtualizer]);
2889
+
2890
+ /**
2891
+ * Drive the dataset-wide `SearchBar` from inside the grid, through the
2892
+ * machinery that already exists — the dataset filters and the page reloads.
2893
+ * Its one caller today is the zero-result state's "clear the search" link.
2894
+ */
2895
+ const handleSearchWholeDataset = useCallback((query: string) => {
2896
+ if (!tableRef.current) return;
2897
+ dispatch(tableRef.current, TableEvents.SEARCH, { query, timestamp: Date.now() });
2898
+ }, [tableRef]);
2899
+
2900
+ /**
2901
+ * Clear the dataset-wide search from the zero-result state (ledger 2.20).
2902
+ *
2903
+ * Goes through the SAME `TableEvents.SEARCH` dispatch the SearchBar uses, so
2904
+ * the input box empties too — `SearchBar` mirrors a search dispatched by
2905
+ * anyone else. Anything that reached in and reset host state directly would
2906
+ * leave the box still showing the dead term.
2907
+ */
2908
+ const handleClearSearch = useCallback(() => {
2909
+ handleSearchWholeDataset('');
2910
+ }, [handleSearchWholeDataset]);
2911
+
2912
+
2913
+ /**
2914
+ * Ctrl/Cmd+V. Reads the RICH flavour first (`text/html`) and only falls back
2915
+ * to TSV — Excel's `text/plain` mangles any cell containing a tab or a
2916
+ * newline, and its HTML flavour does not.
2917
+ *
2918
+ * Geometry follows Excel: the grid lands at the selection's top-left corner;
2919
+ * when the selection is an exact multiple of the copied block in both
2920
+ * directions the block is TILED to fill it (that is how "copy one row, select
2921
+ * ten, paste" is expected to behave). Cells that would land past the last row
2922
+ * or column are still submitted — `applyCellWrites` reports them as
2923
+ * `outOfRange` and the user is told, rather than the paste silently shrinking.
2924
+ */
2925
+ const handlePaste = useCallback((e: ClipboardEvent) => {
2926
+ const container = tableRef.current;
2927
+ if (!container || !(e.target instanceof Node) || !container.contains(e.target)) return;
2928
+ // An open editor owns its own input — never hijack a paste into a text box.
2929
+ if (store.getEditingCell()) return;
2930
+ const bounds = store.getSelectionBounds();
2931
+ if (!bounds) return;
2932
+
2933
+ const grid = parseClipboardGrid({
2934
+ html: e.clipboardData?.getData('text/html'),
2935
+ text: e.clipboardData?.getData('text/plain'),
2936
+ });
2937
+ // Never fail silently: "I pressed Ctrl+V and nothing happened" is
2938
+ // indistinguishable from a broken grid.
2939
+ if (grid.source === 'empty') {
2940
+ flash(t('dynamicTable.paste.empty', 'The clipboard has nothing to paste'), 'info');
2941
+ return;
2942
+ }
2943
+ e.preventDefault();
2944
+
2945
+ const selRows = bounds.endRow - bounds.startRow + 1;
2946
+ const selCols = bounds.endCol - bounds.startCol + 1;
2947
+ const tile =
2948
+ selRows >= grid.rowCount &&
2949
+ selCols >= grid.colCount &&
2950
+ selRows % grid.rowCount === 0 &&
2951
+ selCols % grid.colCount === 0;
2952
+ const spanRows = tile ? selRows : grid.rowCount;
2953
+ const spanCols = tile ? selCols : grid.colCount;
2954
+
2955
+ const writes: CellWrite[] = [];
2956
+ for (let dr = 0; dr < spanRows; dr++) {
2957
+ for (let dc = 0; dc < spanCols; dc++) {
2958
+ writes.push({
2959
+ row: bounds.startRow + dr,
2960
+ col: bounds.startCol + dc,
2961
+ value: grid.rows[dr % grid.rowCount]?.[dc % grid.colCount] ?? '',
2962
+ });
2963
+ }
2964
+ }
2965
+ submitWrites(writes, 'paste');
2966
+ // Excel ends the marquee on paste. Ours must too — and here it is also a
2967
+ // correctness matter: the paste refetches, which can re-order rows under
2968
+ // an index-addressed rectangle.
2969
+ store.setClipboardMarker(null);
2970
+ }, [store, tableRef, submitWrites, t]);
2971
+
2972
+ /**
2973
+ * Every cell of a rectangle, as writes of one value.
2974
+ *
2975
+ * Range-operation contract: the bounds are walked ARITHMETICALLY, so a
2976
+ * selection that spans columns scrolled out of the mounted window (column
2977
+ * virtualization is live on `/backend/invoicing`) clears exactly the same
2978
+ * cells as one that does not. Nothing here asks the DOM what exists.
2979
+ *
2980
+ * Out-of-range cells are deliberately still submitted — `applyCellWrites`
2981
+ * reports them rather than the operation silently shrinking.
2982
+ */
2983
+ const rectWrites = useCallback((
2984
+ bounds: { startRow: number; endRow: number; startCol: number; endCol: number },
2985
+ value: unknown,
2986
+ ): CellWrite[] => {
2987
+ const writes: CellWrite[] = [];
2988
+ for (let r = bounds.startRow; r <= bounds.endRow; r++) {
2989
+ for (let c = bounds.startCol; c <= bounds.endCol; c++) {
2990
+ writes.push({ row: r, col: c, value });
2991
+ }
2992
+ }
2993
+ return writes;
2994
+ }, []);
2995
+
2996
+ /**
2997
+ * Delete / Backspace on a selection: CLEAR CONTENTS, never delete rows.
2998
+ *
2999
+ * `null` (not `''`) is the cleared value — `coerceCellValue` maps both to
3000
+ * `null` for typed columns, but `null` is also right for the untyped ones,
3001
+ * where `''` would persist an empty string into a nullable field.
3002
+ *
3003
+ * Read-only columns and `required` fields are rejected BY the write
3004
+ * primitive and reported by `reportWrite` — a clear that quietly skips half
3005
+ * its cells is the failure mode this replaces.
3006
+ */
3007
+ const clearSelectionContents = useCallback(() => {
3008
+ const bounds = store.getSelectionBounds();
3009
+ if (!bounds) return false;
3010
+ submitWrites(rectWrites(bounds, null), 'clear');
3011
+ return true;
3012
+ }, [store, rectWrites, submitWrites]);
3013
+
3014
+ /**
3015
+ * Ctrl/Cmd+X. Copy, then clear the source — ONE undo entry, because the clear
3016
+ * is a single `applyCellWrites` batch and the copy writes nothing.
3017
+ *
3018
+ * The clipboard is written FIRST and synchronously, inside the native `cut`
3019
+ * event: `navigator.clipboard.writeText` would resolve after the clear and a
3020
+ * failure there would leave the data gone with nothing to paste back.
3021
+ */
3022
+ const handleCut = useCallback((e: ClipboardEvent) => {
3023
+ const container = tableRef.current;
3024
+ if (!container || !(e.target instanceof Node) || !container.contains(e.target)) return;
3025
+ // An open editor owns its own cut — never hijack one out of a text box.
3026
+ if (store.getEditingCell()) return;
3027
+ const bounds = store.getSelectionBounds();
3028
+ const cells = store.getCellsInSelection();
3029
+ if (!bounds || cells.length === 0 || !e.clipboardData) return;
3030
+
3031
+ e.clipboardData.setData('text/plain', selectionToTsv(cells, bounds));
3032
+ e.preventDefault();
3033
+ submitWrites(rectWrites(bounds, null), 'cut');
3034
+ // The ants mark WHAT IS ON THE CLIPBOARD and where it came from. The source
3035
+ // is already empty by this line — see the note above `handleCut` for why
3036
+ // this grid clears on cut rather than on paste.
3037
+ store.setClipboardMarker({ mode: 'cut', bounds });
3038
+ }, [store, tableRef, rectWrites, submitWrites]);
3039
+
3040
+ // ---- Undo / redo ----
3041
+ // Replays the inverse edit through the normal save path, so the reverse also
3042
+ // persists (PATCH/POST) and is itself redoable. `suppressed` stops the replay
3043
+ // from being re-recorded. Rows are resolved by stable id (they survive the
3044
+ // post-edit refetch); rows no longer present are skipped.
3045
+ const applyUndoEntry = useCallback((entry: { changes: { rowId: any; colKey: string; oldValue: any; newValue: any }[] }, useOldValue: boolean) => {
3046
+ store.setUndoSuppressed(true);
3047
+ try {
3048
+ for (const ch of entry.changes) {
3049
+ const rowIdx = store.findRowIndexById(idColumnName, ch.rowId);
3050
+ const colIdx = cols.findIndex((c) => c.data === ch.colKey);
3051
+ if (rowIdx >= 0 && colIdx >= 0) {
3052
+ handleCellSave(rowIdx, colIdx, useOldValue ? ch.oldValue : ch.newValue, false);
3053
+ }
3054
+ }
3055
+ } finally {
3056
+ store.setUndoSuppressed(false);
3057
+ }
3058
+ }, [store, cols, idColumnName, handleCellSave]);
3059
+
3060
+ const undo = useCallback(() => {
3061
+ const entry = store.popUndo();
3062
+ if (!entry) return;
3063
+ applyUndoEntry(entry, true);
3064
+ store.pushRedo(entry);
3065
+ flash(t('dynamicTable.undo.undone', 'Undone'), 'info');
3066
+ }, [store, applyUndoEntry, t]);
3067
+
3068
+ const redo = useCallback(() => {
3069
+ const entry = store.popRedo();
3070
+ if (!entry) return;
3071
+ applyUndoEntry(entry, false);
3072
+ store.pushUndo(entry);
3073
+ flash(t('dynamicTable.undo.redone', 'Redone'), 'info');
3074
+ }, [store, applyUndoEntry, t]);
3075
+
3076
+ // Intercept the built-in selection actions; defer everything else to the
3077
+ // normal cell/row/column dispatch.
3078
+ const handleMenuActionClick = useCallback((actionId: string) => {
3079
+ // Copy → drop the selection (the user is done with it). Comment/colour keeps
3080
+ // it (the dialog needs the visible context); it's cleared on dialog close.
3081
+ if (actionId === SELECTION_COPY_ID) { copySelectionToClipboard(); setContextMenu(null); clearSelectionAnimated(); return; }
3082
+ if (actionId === SELECTION_ANNOTATE_ID) { openAnnotateForSelection(); setContextMenu(null); return; }
3083
+ handleContextMenuAction(actionId);
3084
+ }, [copySelectionToClipboard, openAnnotateForSelection, handleContextMenuAction, clearSelectionAnimated]);
3085
+
3086
+ // Right-click on a cell ALWAYS opens the context menu — even on a single cell
3087
+ // (the drag-end menu in handleMouseUpWithMenu only fires for multi-cell ranges).
3088
+ // The clicked cell is selected first so the built-in Copy / Comment actions
3089
+ // operate on it; built-in actions are merged with any consumer `cellActions`.
3090
+ const handleCellContextMenu = useCallback((e: React.MouseEvent, rowIndex: number, colIndex: number) => {
3091
+ const rowData = store.getRowData(rowIndex);
3092
+ const col = cols[colIndex];
3093
+ if (!col) return;
3094
+ // Select the right-clicked cell only when it isn't already inside the current
3095
+ // selection — otherwise a right-click on a multi-cell range would collapse it
3096
+ // to the single cell and Copy/Comment would lose the range.
3097
+ const bounds = store.getSelectionBounds();
3098
+ const insideSelection = !!bounds
3099
+ && rowIndex >= bounds.startRow && rowIndex <= bounds.endRow
3100
+ && colIndex >= bounds.startCol && colIndex <= bounds.endCol;
3101
+ if (!insideSelection) {
3102
+ store.setSelection({ type: 'range', anchor: { row: rowIndex, col: colIndex }, focus: { row: rowIndex, col: colIndex } });
3103
+ }
3104
+ const actions: ContextMenuAction[] = [
3105
+ { id: SELECTION_COPY_ID, label: t('dynamicTable.cellMenu.copy', 'Copy') },
3106
+ ];
3107
+ if (enableComments && commentsEntityType) {
3108
+ actions.push({ id: SELECTION_ANNOTATE_ID, label: t('dynamicTable.cellMenu.annotate', 'Comment / colour') });
3109
+ }
3110
+ if (cellActions) actions.push(...cellActions(rowData, col, rowIndex, colIndex));
3111
+ e.preventDefault();
3112
+ setContextMenu({ isOpen: true, position: { x: e.clientX, y: e.clientY }, actions, type: 'cell', index: rowIndex, colIndex });
3113
+ }, [cellActions, store, cols, enableComments, commentsEntityType, t]);
3114
+
3115
+ // Stable identity so CellCommentDialog's outside-click listener isn't churned
3116
+ // (re-added with a delay) on every parent render — otherwise the first click
3117
+ // outside clears the selection but the dialog needs a second click to close.
3118
+ // Closing it both drops the selection AND closes the dialog in one click.
3119
+ const handleCommentDialogClose = useCallback(() => {
3120
+ setCommentDialog(null);
3121
+ clearSelectionAnimated();
3122
+ }, [clearSelectionAnimated]);
3123
+
3124
+ // Wrap keyboard handler for React event system
3125
+ // Shortcuts are checked first; if one matches, skip normal navigation
3126
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
3127
+ // Undo / redo. Cmd/Ctrl+Z (undo), Cmd/Ctrl+Shift+Z or Ctrl+Y (redo). Never
3128
+ // hijacked while a cell editor / search box / textarea has focus — there the
3129
+ // input's own native undo must win.
3130
+ const key = e.key.toLowerCase();
3131
+ const metaOrCtrl = e.ctrlKey || e.metaKey;
3132
+ // B9 — Shift+F2 opens the comment dialog for the current selection. Excel's
3133
+ // own "Insert/Edit Comment" chord, adopted verbatim: the workshop finding
3134
+ // was that the feature existed but nobody (including its author) could find
3135
+ // the way in, so it gets the shortcut users already have in their fingers.
3136
+ if (e.key === 'F2' && e.shiftKey && enableComments && commentsEntityType) {
3137
+ if (!isTextEntryTarget(e.target as HTMLElement) && !store.getEditingCell()) {
3138
+ e.preventDefault();
3139
+ openAnnotateForSelection();
3140
+ return;
3141
+ }
3142
+ }
3143
+ if (metaOrCtrl && (key === 'z' || key === 'y')) {
3144
+ const isTextEntry = isTextEntryTarget(e.target as HTMLElement);
3145
+ if (!isTextEntry && !store.getEditingCell()) {
3146
+ e.preventDefault();
3147
+ if (key === 'y' || (key === 'z' && e.shiftKey)) redo();
3148
+ else undo();
3149
+ return;
3150
+ }
3151
+ }
3152
+ // Cmd/Ctrl + D with checkbox-selected rows → bulk delete, identical to the
3153
+ // grouped-actions bar's Delete button (so the consumer's confirm modal still
3154
+ // applies). preventDefault also suppresses the browser's bookmark-page
3155
+ // default. Plain Delete/Backspace is deliberately NOT used — in spreadsheets
3156
+ // those clear cell contents, not the row. Guarded so it never fires from a
3157
+ // text entry (search box, cell editor).
3158
+ const isDeleteCombo = e.key.toLowerCase() === 'd' && (e.ctrlKey || e.metaKey);
3159
+ if (isDeleteCombo && selectable && onBulkDelete && selectedRowIds.size > 0) {
3160
+ if (!isTextEntryTarget(e.target as HTMLElement) && !store.getEditingCell()) {
3161
+ e.preventDefault();
3162
+ void handleBulkDelete();
3163
+ return;
3164
+ }
3165
+ }
3166
+ // Plain Delete / Backspace on a selection CLEARS CONTENTS — the spreadsheet
3167
+ // meaning. The ROW is never touched: bulk row deletion stays on Cmd/Ctrl+D
3168
+ // and the grouped-actions bar, both of which route through the consumer's
3169
+ // confirm modal.
3170
+ if ((e.key === 'Delete' || e.key === 'Backspace') && !metaOrCtrl && !e.altKey) {
3171
+ if (!isTextEntryTarget(e.target as HTMLElement) && !store.getEditingCell()) {
3172
+ // preventDefault only when there IS something to clear, so Backspace
3173
+ // outside a selection still bubbles (it is "back" on some browsers).
3174
+ if (clearSelectionContents()) {
3175
+ e.preventDefault();
3176
+ return;
3177
+ }
3178
+ }
3179
+ }
3180
+ // Ctrl+H (control key only — Cmd+H hides the application on macOS) opens
3181
+ // FIND AND REPLACE, which is Excel's binding on both platforms. It is an
3182
+ // accelerator, never the only path: the same tool is one click away in the
3183
+ // toolbar, because a shortcut nobody can discover may as well not exist.
3184
+ //
3185
+ // Ctrl+F belongs to the BROWSER. Every list carries a Search box in the
3186
+ // toolbar that filters the whole dataset server-side; an in-grid finder
3187
+ // searching only the loaded page was a second, weaker search stacked under
3188
+ // the real one, so it was removed rather than kept behind a flag.
3189
+ // The rung below the bar and above "exit editing": Escape dismisses the
3190
+ // MARCHING ANTS first, exactly as Excel cancels its marquee before it does
3191
+ // anything else. Skipped while an editor is open — there the first Escape
3192
+ // still belongs to the edit session.
3193
+ if (e.key === 'Escape' && !store.getEditingCell() && store.getClipboardMarker()) {
3194
+ e.preventDefault();
3195
+ store.setClipboardMarker(null);
3196
+ return;
3197
+ }
3198
+ // Enter on a cell whose column mounts NO editor. `useKeyboardNavigation`
3199
+ // already refuses to open the edit session (without that guard the cell
3200
+ // blanks and every arrow key afterwards is swallowed — ledger 1.27); this
3201
+ // rung exists only to say WHY nothing happened, because a key that silently
3202
+ // does nothing is indistinguishable from a broken grid. Raised here rather
3203
+ // than in the hook because `flash` and the translations live at this level.
3204
+ if (e.key === 'Enter' && !e.shiftKey && !metaOrCtrl && !store.getEditingCell()) {
3205
+ const b = store.getSelectionBounds();
3206
+ if (b && b.startRow === b.endRow && b.startCol === b.endCol && !columnMountsEditor(cols[b.startCol])) {
3207
+ e.preventDefault();
3208
+ flash(
3209
+ t('dynamicTable.cell.readOnlyColumn', '"{column}" is read-only', {
3210
+ column: String(cols[b.startCol]?.title ?? cols[b.startCol]?.data ?? ''),
3211
+ }),
3212
+ 'info',
3213
+ );
3214
+ return;
3215
+ }
3216
+ }
3217
+ if (shortcutHandler(e.nativeEvent)) return;
3218
+ keyboardHandler(e.nativeEvent);
3219
+ }, [keyboardHandler, shortcutHandler, selectable, onBulkDelete, selectedRowIds, store, handleBulkDelete, undo, redo, clearSelectionContents, enableComments, commentsEntityType, openAnnotateForSelection, cols, t]);
3220
+
3221
+ // Auto-select cell on focus (when enabled and no existing selection).
3222
+ // Reads optional data-focus-direction / data-focus-trigger attributes set
3223
+ // by the cross-table arrow and Tab handlers.
3224
+ const handleFocus = useCallback(() => {
3225
+ const direction = tableRef.current?.getAttribute('data-focus-direction');
3226
+ const trigger = tableRef.current?.getAttribute('data-focus-trigger');
3227
+ if (direction) tableRef.current?.removeAttribute('data-focus-direction');
3228
+ if (trigger) tableRef.current?.removeAttribute('data-focus-trigger');
3229
+
3230
+ if (store.getRowCount() === 0) {
3231
+ // Empty table: if Tab-triggered, forward to the next sibling in the
3232
+ // same direction so empty tables are transparently skipped.
3233
+ if (trigger === 'tab') {
3234
+ const nextSibling = direction === 'up'
3235
+ ? siblingTableRefs?.prev?.current
3236
+ : siblingTableRefs?.next?.current;
3237
+ if (nextSibling) {
3238
+ nextSibling.setAttribute('data-focus-direction', direction || 'down');
3239
+ nextSibling.setAttribute('data-focus-trigger', 'tab');
3240
+ nextSibling.focus();
3241
+ }
3242
+ }
3243
+ return;
3244
+ }
3245
+
3246
+ if (!autoSelectOnFocus || store.getSelection().anchor) return;
3247
+
3248
+ const rowCount = store.getRowCount();
3249
+ const targetRow = direction === 'up' ? rowCount - 1 : 0;
3250
+ store.setSelection({
3251
+ type: 'range',
3252
+ anchor: { row: targetRow, col: 0 },
3253
+ focus: { row: targetRow, col: 0 },
3254
+ });
3255
+ }, [autoSelectOnFocus, store, tableRef, siblingTableRefs]);
3256
+
3257
+ // Returns true when the target element sits inside a modal dialog that
3258
+ // does NOT contain this table. Modal dialogs (delete confirmations,
3259
+ // forms) are rendered as sibling portals — keeping selection while
3260
+ // they're open is correct because they return focus to the table on
3261
+ // close via onCloseAutoFocus. Drawers/Sheets also carry
3262
+ // `role="dialog"` but they *contain* the table, so they must NOT be
3263
+ // exempted (sibling tables inside the same drawer need independent
3264
+ // selection clearing).
3265
+ const isInsideExternalDialog = useCallback((el: HTMLElement): boolean => {
3266
+ const dialog = el.closest('[role="dialog"]');
3267
+ if (!dialog) return false;
3268
+ return !dialog.contains(tableRef?.current);
3269
+ }, [tableRef]);
3270
+
3271
+ // Clear selection when DOM focus leaves the table container.
3272
+ // This ensures that when a user clicks on another table (or any element
3273
+ // outside this table), the stale selection is removed so only the newly
3274
+ // focused table shows a highlight. We skip clearing when focus moves to
3275
+ // portal-rendered popups (date pickers, dropdowns, entity search) that
3276
+ // logically belong to this table even though they live outside its DOM.
3277
+ const handleBlur = useCallback((e: React.FocusEvent) => {
3278
+ const relatedTarget = e.relatedTarget as HTMLElement | null;
3279
+ // Focus left the window entirely (e.g. alt-tab) — keep selection.
3280
+ if (!relatedTarget) return;
3281
+ // Focus stayed inside our table container — nothing to clear.
3282
+ if (tableRef?.current?.contains(relatedTarget)) return;
3283
+ // Focus moved to a portal popup (Radix dropdown/popover, editor popup
3284
+ // like calendar or dropdown, context menu) that belongs to this table —
3285
+ // keep selection.
3286
+ if (relatedTarget.closest('[data-radix-popper-content-wrapper]') ||
3287
+ relatedTarget.closest('.hot-editor-popup') ||
3288
+ relatedTarget.closest('.hot-context-menu') ||
3289
+ relatedTarget.closest('.context-menu') ||
3290
+ // The find-and-replace bar renders OUTSIDE the grid container (it has
3291
+ // grid. Without this exemption, clicking into it would clear the very
3292
+ // selection the active match just established.
3293
+ relatedTarget.closest('.hot-comment-popover')) return;
3294
+ // Focus moved to a modal dialog (delete confirmation, form, etc.)
3295
+ // that does NOT contain this table — keep selection; the dialog will
3296
+ // return focus on close via onCloseAutoFocus.
3297
+ if (isInsideExternalDialog(relatedTarget)) return;
3298
+
3299
+ store.clearEditing();
3300
+ clearSelectionAnimated();
3301
+ }, [store, tableRef, isInsideExternalDialog, clearSelectionAnimated]);
3302
+
3303
+ // -------------------- FULLSCREEN HANDLERS --------------------
3304
+ // No width bookkeeping here any more. Entering fullscreen used to scale every
3305
+ // column by `windowWidth / currentTotal` and exiting used to restore the
3306
+ // saved map — which overwrote the user's own widths on the way in and could
3307
+ // resurrect stale ones on the way out. The fill effect above already does the
3308
+ // job properly: the scroller resizes, its `ResizeObserver` fires, and the
3309
+ // surplus is redistributed across the columns that are allowed to take it.
3310
+ const handleEnterFullscreen = () => {
3311
+ setIsFullscreen(true);
3312
+ onFullscreenChange?.(true);
3313
+ };
3314
+
3315
+ const handleExitFullscreen = () => {
3316
+ setIsFullscreen(false);
3317
+ onFullscreenChange?.(false);
3318
+ };
3319
+
3320
+ // -------------------- EFFECTS --------------------
3321
+ // Register table container ref with store for focus management
3322
+ useEffect(() => {
3323
+ store.setTableRef(tableRef);
3324
+ }, [store, tableRef]);
3325
+
3326
+ // Sync data to store.
3327
+ // Skip when the store contains unsaved new rows to prevent wiping
3328
+ // in-progress edits (e.g., dropdown selections in insert mode).
3329
+ useEffect(() => {
3330
+ if (store.hasNewRows()) return;
3331
+ store.setData(data);
3332
+ }, [data, store]);
3333
+
3334
+ // Sync columns to store when they change (e.g., perspective reorder/hide/show).
3335
+ // This ensures setCellValue uses the correct field mapping.
3336
+ useEffect(() => {
3337
+ store.setColumns(cols);
3338
+ }, [cols, store]);
3339
+
3340
+ // Persist manual column-width changes per user (localStorage). Saved widths are
3341
+ // applied in a pre-paint layout effect (no layout shift on reload), and the
3342
+ // store preserves widths across setColumns() so perspective reorder/show-hide
3343
+ // keeps them.
3344
+ const { persistResize } = useColumnWidthPersistence({ tableId, store, cols, userResizedColsRef });
3345
+ persistColumnWidthRef.current = persistResize;
3346
+
3347
+ // Subscribe to store-level changes (row add/remove, column resize)
3348
+ useEffect(() => {
3349
+ return store.subscribeToStore(() => {
3350
+ setRowCount(store.getRowCount());
3351
+ setStoreRevision(prev => prev + 1);
3352
+ });
3353
+ }, [store]);
3354
+
3355
+ // Auto-fit badge columns to their widest badge. Unlike text (which ellipses),
3356
+ // status/type pills must never be clipped — so the column grows to fit the
3357
+ // longest badge instead.
3358
+ //
3359
+ // The measurement has to touch the DOM (a consumer's custom renderer can emit
3360
+ // any label, so there is no string in the model to measure), but everything
3361
+ // else is model-driven — see `utils/badgeAutoFit.ts` for the full rationale.
3362
+ // Two properties matter and are deliberate:
3363
+ //
3364
+ // - What we read is INTRINSIC: `badge.scrollWidth` is the pill's own content
3365
+ // width. It does not depend on the cell's current width, the scroll
3366
+ // position, or which columns happen to be mounted. The old code compared it
3367
+ // against `td.clientWidth`, which made the answer geometry-coupled — under
3368
+ // column virtualization that is a scroll-coupled layout feedback loop
3369
+ // (column widens on mount → offsets shift → another column mounts → …).
3370
+ // - The result is cached by the stable `col.data` key and re-applied to
3371
+ // whatever index that key currently occupies, INCLUDING columns that are
3372
+ // not mounted. So `store.getColumnWidth(i)` answers for every `i`, and a
3373
+ // fitted width follows its column through a perspective reorder.
3374
+ //
3375
+ // Grow-only and idempotent: once a column is wide enough it produces no write,
3376
+ // so no write can trigger another pass. Skips columns the user resized by
3377
+ // hand. This effect is NOT subscribed to scroll and must never become so.
3378
+ useEffect(() => {
3379
+ const root = tableRef.current;
3380
+ if (!root) return;
3381
+ const raf = requestAnimationFrame(() => {
3382
+ const cache = badgeFitRef.current;
3383
+ root.querySelectorAll<HTMLElement>('td.hot-cell .cell-badge').forEach((badge) => {
3384
+ const td = badge.closest('td[data-col]') as HTMLElement | null;
3385
+ if (!td) return;
3386
+ const colIndex = parseInt(td.getAttribute('data-col') || '', 10);
3387
+ // Cache by the stable data key, never the index — a reorder must not
3388
+ // move one column's fitted width onto another.
3389
+ const key = cols[colIndex]?.data;
3390
+ if (!key) return;
3391
+ const hasComment = td.getAttribute('data-has-comment') === 'true';
3392
+ recordBadgeFit(cache, key, badgeFitWidth(badge.scrollWidth, hasComment));
3393
+ });
3394
+ // In stretch mode this raises the column's flex-basis + min-width
3395
+ // (Cell.tsx), so the badge column no longer shrinks below the badge;
3396
+ // in fixed mode it simply widens the column.
3397
+ planBadgeWidths(
3398
+ cache,
3399
+ cols,
3400
+ // The DECLARED width, never the painted one: the fill surplus is not
3401
+ // the column's own width, and comparing against it would let a badge
3402
+ // stay clipped the moment the container narrows and the surplus goes.
3403
+ (col) => store.getBaseColumnWidth(col),
3404
+ (col) => userResizedColsRef.current.has(col),
3405
+ ).forEach(({ col, width }) => store.setColumnWidth(col, width));
3406
+ });
3407
+ return () => cancelAnimationFrame(raf);
3408
+ }, [data, cols, store, tableRef, annotations]);
3409
+
3410
+ // Colour-block adjacency (the `data-cb-{above,below,left,right}` attributes)
3411
+ // used to be discovered here by running `querySelectorAll` over the rendered
3412
+ // cells on every scroll event. That effect is GONE — anchor Defect 7. It read
3413
+ // facts about the data out of the DOM, so it was wrong for any neighbour
3414
+ // outside the virtualization window (a painted gap and a rounded corner in
3415
+ // the middle of a colour run, moving as you scroll) and it put a full-grid
3416
+ // DOM query on the scroll path. Adjacency is now computed in the model by
3417
+ // `utils/colorAdjacency.ts` and passed to `Cell` as props. Do not reinstate a
3418
+ // DOM scan; throttling one would not make it correct.
3419
+
3420
+ // Scroll highlighted row into view when highlightedRowId changes.
3421
+ // Only scrolls if the row is not already visible.
3422
+ useEffect(() => {
3423
+ if (!highlightedRowId || !tableRef?.current) return;
3424
+
3425
+ // Find the row index by ID
3426
+ const rowIndex = data.findIndex(row => row[idColumnName] === highlightedRowId);
3427
+ if (rowIndex === -1) return;
3428
+
3429
+ // Use the virtualizer to scroll to this row
3430
+ // scrollToIndex brings the row into view with 'auto' behavior (minimal scroll)
3431
+ rowVirtualizer.scrollToIndex(rowIndex, { align: 'center', behavior: 'smooth' });
3432
+ }, [highlightedRowId, data, idColumnName, rowVirtualizer]);
3433
+
3434
+ // Keyboard navigation is now handled via onKeyDown prop on the table container
3435
+ // This ensures React synthetic events fire before the handler, allowing editors to save first
3436
+
3437
+ // Copy handler
3438
+ useEffect(() => {
3439
+ document.addEventListener('copy', handleCopy);
3440
+ return () => document.removeEventListener('copy', handleCopy);
3441
+ }, [handleCopy]);
3442
+
3443
+ // Cut handler — the native `cut` event, not a Ctrl+X keybinding, so the
3444
+ // clipboard write is a user-gesture write the browser actually permits and
3445
+ // the OS-level Edit > Cut menu item works too.
3446
+ useEffect(() => {
3447
+ document.addEventListener('cut', handleCut);
3448
+ return () => document.removeEventListener('cut', handleCut);
3449
+ }, [handleCut]);
3450
+
3451
+ // Paste handler — same document-level attachment as copy, and the handler
3452
+ // itself checks the event target is inside THIS table, so several grids on
3453
+ // one page never fight over a paste.
3454
+ useEffect(() => {
3455
+ document.addEventListener('paste', handlePaste);
3456
+ return () => document.removeEventListener('paste', handlePaste);
3457
+ }, [handlePaste]);
3458
+
3459
+ // Global mouse up for drag end
3460
+ useEffect(() => {
3461
+ const handleGlobalMouseUp = () => {
3462
+ // Commit a fill that was released outside the table container. The
3463
+ // container's own onMouseUp commits first when released inside it, after
3464
+ // which fillStateRef is null and this is a no-op (no double-commit).
3465
+ if (fillStateRef.current) {
3466
+ commitFill();
3467
+ }
3468
+ if (dragStateRef.current.isDragging) {
3469
+ dragHandlers.handleDragEnd();
3470
+ }
3471
+ };
3472
+
3473
+ document.addEventListener('mouseup', handleGlobalMouseUp);
3474
+ return () => document.removeEventListener('mouseup', handleGlobalMouseUp);
3475
+ }, [dragHandlers, commitFill]);
3476
+
3477
+ // Click outside handler to clear selection.
3478
+ // When multiple DynamicTables coexist inside a dialog/drawer, clicking on
3479
+ // another table must clear THIS table's selection. Portal-rendered popups
3480
+ // (date pickers, dropdowns, context menus) are excluded so interacting with
3481
+ // them doesn't accidentally clear the selection.
3482
+ useEffect(() => {
3483
+ const handleClickOutside = (e: MouseEvent) => {
3484
+ const target = e.target as HTMLElement;
3485
+
3486
+ // Click is inside our own table container — keep selection.
3487
+ if (tableRef?.current?.contains(target)) {
3488
+ return;
3489
+ }
3490
+
3491
+ // Click is inside a Radix portal popup, context menu, or there's an active
3492
+ // editor popup anywhere in the DOM. For editor popups, we check existence
3493
+ // (not containment) because clicking OUTSIDE the popup to close it should
3494
+ // let the editor's own click-outside handler save the value first.
3495
+ if (target.closest('[data-radix-popper-content-wrapper]') ||
3496
+ target.closest('.hot-context-menu') ||
3497
+ target.closest('.context-menu') ||
3498
+ target.closest('.hot-comment-popover') ||
3499
+ document.querySelector('.hot-editor-popup')) {
3500
+ return;
3501
+ }
3502
+
3503
+ // Click landed inside a modal dialog that does NOT contain this
3504
+ // table (e.g., delete confirmation overlay) — keep selection.
3505
+ // Drawers/Sheets also have `role="dialog"` but they *contain* the
3506
+ // table, so clicks inside the same drawer still clear selection.
3507
+ if (isInsideExternalDialog(target)) {
3508
+ return;
3509
+ }
3510
+
3511
+ // If there's an active editing cell, defer clearing so the editor's blur
3512
+ // handler has a chance to save the value first. mousedown fires before blur,
3513
+ // so without this delay the editor unmounts before onBlur can call onSave.
3514
+ const editingCell = store.getEditingCell();
3515
+ if (editingCell) {
3516
+ setTimeout(() => {
3517
+ store.clearEditing();
3518
+ clearSelectionAnimated();
3519
+ }, 0);
3520
+ } else {
3521
+ store.clearEditing();
3522
+ clearSelectionAnimated();
3523
+ }
3524
+ };
3525
+
3526
+ document.addEventListener('mousedown', handleClickOutside);
3527
+ return () => document.removeEventListener('mousedown', handleClickOutside);
3528
+ }, [store, tableRef, isInsideExternalDialog, clearSelectionAnimated]);
3529
+
3530
+ // Dispatch FILTER_CHANGE when filters change (backward compatibility)
3531
+ useEffect(() => {
3532
+ if (!tableRef?.current) return;
3533
+ dispatch<FilterChangeEvent>(
3534
+ tableRef.current,
3535
+ TableEvents.FILTER_CHANGE,
3536
+ { filters, savedFilterId: activePerspectiveId },
3537
+ );
3538
+ }, [filters, activePerspectiveId, tableRef]);
3539
+
3540
+ /**
3541
+ * Dispatch PERSPECTIVE_CHANGE event whenever table configuration changes.
3542
+ *
3543
+ * PURPOSE:
3544
+ * Notifies parent components and event listeners when the table's perspective
3545
+ * settings change (filters, sorting, column visibility). This enables:
3546
+ * - Parent components to sync URL parameters with active filters
3547
+ * - External state management to track table configuration
3548
+ * - Analytics/logging of user interactions with the table
3549
+ *
3550
+ * IMPORTANT:
3551
+ * Parent components handling this event should be careful not to create infinite
3552
+ * loops. Common patterns:
3553
+ * - Extract only sorting changes: `if (payload.config.sorting) { ... }`
3554
+ * - Use refs to track initialization state before updating controlled props
3555
+ * - Avoid re-setting `activePerspectiveId` in response to this event unless
3556
+ * implementing specific logic like URL sync
3557
+ *
3558
+ * This event fires for ALL config changes, not just user interactions. It will
3559
+ * fire when:
3560
+ * - User adds/removes filters via UI
3561
+ * - User clicks column headers to sort
3562
+ * - User shows/hides columns
3563
+ * - Parent component applies a perspective via `activePerspectiveId` prop
3564
+ * (via the perspective sync effect above)
3565
+ *
3566
+ * See the `useDynamicTablePage` hook's PERSPECTIVE_CHANGE handling for an example of safe usage.
3567
+ */
3568
+ useEffect(() => {
3569
+ if (!tableRef?.current) return;
3570
+ dispatch<PerspectiveChangeEvent>(
3571
+ tableRef.current,
3572
+ TableEvents.PERSPECTIVE_CHANGE,
3573
+ {
3574
+ config: {
3575
+ columns: { visible: visibleColumns, hidden: hiddenColumns },
3576
+ filters,
3577
+ sorting: sortRules,
3578
+ grouping: groupRules,
3579
+ aggregations,
3580
+ lookupColumns,
3581
+ rollupColumns,
3582
+ formulas,
3583
+ conditionalFormats,
3584
+ dateFormat,
3585
+ viewMode,
3586
+ },
3587
+ },
3588
+ );
3589
+ }, [visibleColumns, hiddenColumns, filters, sortRules, groupRules, aggregations, lookupColumns, rollupColumns, formulas, conditionalFormats, dateFormat, viewMode, tableRef]);
3590
+
3591
+ // -------------------- EVENT HANDLERS --------------------
3592
+ useEventHandlers({
3593
+ [TableEvents.CELL_SAVE_START]: (payload) => {
3594
+ store.setSaveState(payload.rowIndex, payload.colIndex, 'saving');
3595
+ },
3596
+ [TableEvents.CELL_SAVE_SUCCESS]: (payload) => {
3597
+ store.setSaveState(payload.rowIndex, payload.colIndex, 'success');
3598
+ setTimeout(() => store.setSaveState(payload.rowIndex, payload.colIndex, null), 2000);
3599
+ },
3600
+ [TableEvents.CELL_SAVE_ERROR]: (payload) => {
3601
+ store.setSaveState(payload.rowIndex, payload.colIndex, 'error');
3602
+ setTimeout(() => store.setSaveState(payload.rowIndex, payload.colIndex, null), 3000);
3603
+ },
3604
+ [TableEvents.NEW_ROW_SAVE_SUCCESS]: (payload) => {
3605
+ store.markRowAsSaved(payload.rowIndex, payload.savedRowData);
3606
+ },
3607
+ [TableEvents.NEW_ROW_SAVE_ERROR]: (payload) => {
3608
+ console.error('Failed to save new row:', payload.error);
3609
+ // Ledger 4.10 — "Save silently does nothing". The host flashes the reason,
3610
+ // but the GRID said nothing at all: the draft row sat there looking
3611
+ // identical to one that had never been submitted, so the only way to tell
3612
+ // a rejected create from a slow one was to reload. Paint the failure on
3613
+ // the row and put the caret back in the first cell, which is the field
3614
+ // every rejection so far has been about (an empty required name).
3615
+ if (store.isNewRow(payload.rowIndex)) {
3616
+ store.setSaveState(payload.rowIndex, 0, 'error');
3617
+ setTimeout(() => store.setSaveState(payload.rowIndex, 0, null), 3000);
3618
+ store.setEditingCell(payload.rowIndex, 0);
3619
+ }
3620
+ },
3621
+ }, tableRef);
3622
+
3623
+ // -------------------- RENDER --------------------
3624
+
3625
+ // Determine if we should fill available height
3626
+ const shouldFillHeight = height === '100%' || height === 'fill'
3627
+ const isViewportFill = height === 'fill'
3628
+ const outerContainerRef = useRef<HTMLDivElement>(null)
3629
+ const [fillHeight, setFillHeight] = useState<number | null>(null)
3630
+
3631
+ // When height='fill', measure available viewport space and set explicit height.
3632
+ // This avoids relying on CSS flex chain from parent containers.
3633
+ useLayoutEffect(() => {
3634
+ if (!isViewportFill || isFullscreen) return
3635
+ const el = outerContainerRef.current
3636
+ if (!el) return
3637
+ // Find the nearest SCROLL-PANE ancestor (e.g. the app shell's scroll
3638
+ // container). Fill to ITS visible bottom, not the raw viewport — otherwise
3639
+ // the table runs under page chrome (app-shell footer) that lives below the
3640
+ // pane and the table's own footer ends up below the fold.
3641
+ let scrollPane: HTMLElement | null = el.parentElement
3642
+ while (scrollPane && scrollPane !== document.body) {
3643
+ const oy = getComputedStyle(scrollPane).overflowY
3644
+ if (oy === 'auto' || oy === 'scroll') break
3645
+ scrollPane = scrollPane.parentElement
3646
+ }
3647
+ const measure = () => {
3648
+ const rect = el.getBoundingClientRect()
3649
+ // Use the pane's CLIENT-area bottom (`top + clientHeight`), not
3650
+ // getBoundingClientRect().bottom: clientHeight is the stable laid-out
3651
+ // inner height and excludes transient content overflow, so the measure
3652
+ // doesn't overshoot while the flex layout is still settling.
3653
+ const paneBottom = scrollPane && scrollPane !== document.body
3654
+ ? Math.min(window.innerHeight, Math.round(scrollPane.getBoundingClientRect().top) + scrollPane.clientHeight)
3655
+ : window.innerHeight
3656
+ setFillHeight(Math.max(Math.floor(paneBottom - rect.top), 200))
3657
+ }
3658
+ measure()
3659
+ window.addEventListener('resize', measure)
3660
+ // Re-measure when the pane (or container) resizes — the app-shell panes
3661
+ // settle a few px after the first layout pass, which the one-shot measure
3662
+ // above would otherwise miss (leaving the footer a few px below the fold).
3663
+ const ro = new ResizeObserver(measure)
3664
+ if (scrollPane && scrollPane !== document.body) ro.observe(scrollPane)
3665
+ return () => {
3666
+ window.removeEventListener('resize', measure)
3667
+ ro.disconnect()
3668
+ }
3669
+ }, [isViewportFill, isFullscreen])
3670
+
3671
+ // Table content shared between normal and fullscreen modes
3672
+ const tableContent = (
3673
+ <div
3674
+ ref={outerContainerRef}
3675
+ className={`hot-container ${shouldFillHeight ? 'flex flex-col flex-1' : ''}${borderless ? ' hot-borderless' : ''} hot-appearance-v2`}
3676
+ data-range-phase={rangePhase === 'idle' ? undefined : rangePhase}
3677
+ data-readonly-style={readOnlyStyle}
3678
+ data-density={density || undefined}
3679
+ {...densityAttribute}
3680
+ data-striped={striped ? 'true' : undefined}
3681
+ data-actions-scroll-shadow={actionsScrollShadow ? 'true' : undefined}
3682
+ data-firstcol-scroll-shadow={firstColScrollShadow ? 'true' : undefined}
3683
+ data-frozen-shadow={frozenColShadow ? 'true' : undefined}
3684
+ data-clickable-rows={onRowClick ? 'true' : undefined}
3685
+ data-row-hover-style={onRowClick ? rowHoverStyle : undefined}
3686
+ style={{
3687
+ height: isFullscreen ? '100%' : (isViewportFill && fillHeight ? fillHeight : (shouldFillHeight ? '100%' : height)),
3688
+ width: isFullscreen ? '100%' : width,
3689
+ position: 'relative',
3690
+ ...(shouldFillHeight && { minHeight: 0 }),
3691
+ }}
3692
+ >
3693
+ {/* Toolbar row 1 (Figma 220:2935): [title search] … [add actions expand].
3694
+ Title + search sit on the left; the add-row "+", the consumer slot
3695
+ (searchBarEnd, e.g. the page's primary button) and the fullscreen
3696
+ toggle are right-aligned on the SAME line. */}
3697
+ {/* Lead row: a custom `topBarStart` (e.g. category tabs) gets its own line
3698
+ ABOVE the title on full list pages (those with perspective tabs), so
3699
+ the title + search + buttons row stays uncluttered. Embedded sub-tables
3700
+ (no perspective tabs) keep topBarStart inline in the toolbar below. */}
3701
+ {!hideToolbar && topBarStart && !hidePerspectiveTabs && (
3702
+ <div className="hot-toolbar-lead-row">{topBarStart}</div>
3703
+ )}
3704
+
3705
+ {/* Card wrapper (v2): on full list pages the toolbar + perspective tabs +
3706
+ grid sit on a single white, rounded card floating on the page canvas;
3707
+ the lead row (category tabs) above stays OUTSIDE this card. Embedded
3708
+ sub-tables (no perspective tabs) use `display:contents` so the wrapper
3709
+ is inert and they keep rendering flush. */}
3710
+ <div className={hidePerspectiveTabs ? 'contents' : `hot-card${shouldFillHeight ? ' flex flex-col flex-1 min-h-0' : ''}`}>
3711
+
3712
+ {!hideToolbar && (
3713
+ <div className="hot-toolbar">
3714
+ {/* Custom slot: top bar start (inline only for embedded tables) */}
3715
+ {hidePerspectiveTabs && topBarStart}
3716
+
3717
+ {!hideTitle && (
3718
+ <h3 className="hot-toolbar-title">{displayTableName}</h3>
3719
+ )}
3720
+
3721
+ {!hideSearch && (
3722
+ <SearchBar
3723
+ tableRef={tableRef}
3724
+ placeholder={searchPlaceholder ?? 'Search...'}
3725
+ debounceMs={searchDebounceMs}
3726
+ renderSuggestions={searchSuggestions}
3727
+ />
3728
+ )}
3729
+
3730
+ {/* Spacer */}
3731
+ <div className="hot-toolbar-spacer" />
3732
+
3733
+ <div className="hot-toolbar-actions">
3734
+ {/* A13 — WHICH filters are running, and the one-click way out of
3735
+ any single one of them. Rendered here, immediately before the
3736
+ clear-all, so the chips and their clear-all read as one group;
3737
+ it adds no toolbar row — the strip scrolls inside this one. */}
3738
+ <ActiveFilterChips
3739
+ filters={filters}
3740
+ columns={baseColumns}
3741
+ onRemove={(filterId) => handleFiltersChange(filters.filter((f) => f.id !== filterId))}
3742
+ />
3743
+ {/* The one place that answers "am I actually seeing everything?"
3744
+ for the whole grid, and the one-click way back to everything.
3745
+ These rules are TEMPORARY and are never written to the saved
3746
+ view (A2/D6). */}
3747
+ {filters.length > 0 && (
3748
+ <button
3749
+ type="button"
3750
+ className="hot-clear-filters-btn"
3751
+ onClick={() => handleFiltersChange([])}
3752
+ title={t(
3753
+ 'dynamicTable.quickFilter.clearAllHint',
3754
+ 'These filters are temporary and are not saved with the view',
3755
+ )}
3756
+ data-clear-all-filters=""
3757
+ >
3758
+ <FunnelX className="w-3.5 h-3.5" aria-hidden="true" />
3759
+ <span className="text-body-regular-xs">
3760
+ {t('dynamicTable.quickFilter.clearAll', 'Clear all filters ({count})', {
3761
+ count: filters.length,
3762
+ })}
3763
+ </span>
3764
+ </button>
3765
+ )}
3766
+ {/* A function gets the filter pair, so a header can narrow the
3767
+ table in BOTH view modes — see `SearchBarRenderContext`. */}
3768
+ {typeof searchBarStart === 'function'
3769
+ ? searchBarStart({ data, filters, setFilters: handleFiltersChange })
3770
+ : searchBarStart}
3771
+ {!hideAddRowButton && (
3772
+ <button
3773
+ onClick={handleAddRow}
3774
+ className="hot-add-row-btn"
3775
+ title="Add new row"
3776
+ >
3777
+ +
3778
+ </button>
3779
+ )}
3780
+ {searchBarEnd}
3781
+ {/* Export, row height and fullscreen are SECONDARY controls. Each
3782
+ one used to spend toolbar width on every table in the app,
3783
+ crowding the primary action a page actually cares about. They
3784
+ now sit behind one overflow button by default; a table that
3785
+ wants them one click away sets `expandToolbarActions`. */}
3786
+ {/* View switcher — list / grid. Sits with density, export and
3787
+ fullscreen because it is the same KIND of control: it changes
3788
+ how you are looking at the rows, not which rows you are looking
3789
+ at. Rendered ahead of the overflow menu so it is never buried:
3790
+ a mode you cannot see you are in is worse than no mode. */}
3791
+ {viewModes && viewModes.length > 1 && (
3792
+ <ViewModeSwitch
3793
+ options={viewModes}
3794
+ value={viewMode}
3795
+ onChange={handleViewModeChange}
3796
+ />
3797
+ )}
3798
+ {expandToolbarActions ? (
3799
+ <>
3800
+ {!hideExportButton && (
3801
+ <ExportMenu variant="toolbar" onExport={handleExportAll} disabled={isExportingAll} />
3802
+ )}
3803
+ {!hideDensityControl && <DensityControl variant="toolbar" tableKey={tableId} />}
3804
+ {enableFullscreen && !isFullscreen && (
3805
+ <button onClick={handleEnterFullscreen} className="fullscreen-toggle-btn" title="Enter fullscreen">
3806
+ <Maximize2 className="w-4 h-4" />
3807
+ </button>
3808
+ )}
3809
+ </>
3810
+ ) : (
3811
+ <ToolbarOverflow
3812
+ onExport={hideExportButton ? undefined : handleExportAll}
3813
+ exportDisabled={isExportingAll}
3814
+ showDensity={!hideDensityControl}
3815
+ densityTableKey={tableId}
3816
+ onFullscreen={enableFullscreen && !isFullscreen ? handleEnterFullscreen : undefined}
3817
+ extras={toolbarOverflowExtras}
3818
+ />
3819
+ )}
3820
+ </div>
3821
+
3822
+ {/* Custom slot: top bar end */}
3823
+ {topBarEnd}
3824
+ </div>
3825
+ )}
3826
+
3827
+ {/* Toolbar row 2 (Figma 220:2935): [perspective tabs] … [pagination].
3828
+ Pagination is right-aligned on the tabs line (no bottom footer). */}
3829
+ {(!hidePerspectiveTabs || (pagination && !hidePagination)) && (
3830
+ <div className="hot-tabs-row">
3831
+ {!hidePerspectiveTabs && (
3832
+ <PerspectiveTabs
3833
+ savedPerspectives={savedPerspectives}
3834
+ activePerspectiveId={activePerspectiveId}
3835
+ onPerspectiveSelect={handlePerspectiveSelect}
3836
+ onPerspectiveRename={handlePerspectiveRename}
3837
+ onPerspectiveDelete={handlePerspectiveDelete}
3838
+ onPerspectiveDuplicate={handlePerspectiveDuplicate}
3839
+ onPerspectiveSetDefault={handlePerspectiveSetDefault}
3840
+ defaultPerspectiveId={defaultPerspectiveId}
3841
+ sharedTemplates={sharedTemplates}
3842
+ publishableRoles={canPublishTemplates ? publishableRoles : undefined}
3843
+ onPerspectivePublish={canPublishTemplates ? handlePerspectivePublish : undefined}
3844
+ onPerspectiveTemplateCopy={handlePerspectiveTemplateCopy}
3845
+ onPerspectiveEdit={(id) => {
3846
+ // Load the perspective's config into the working state, then open
3847
+ // the panel pre-populated for editing. Saving with the same name
3848
+ // updates it in place (save upserts by name).
3849
+ handlePerspectiveSelect(id);
3850
+ setEditingPerspectiveId(id);
3851
+ setConfigureBaseView(false);
3852
+ setConfigPanelInitialSection(null);
3853
+ setConfigPanelOpen(true);
3854
+ }}
3855
+ onConfigureBaseView={() => {
3856
+ // Configure the BASE tab, not a new view: the drawer's Save
3857
+ // then personalizes "Default view" for this user instead of
3858
+ // demanding a name for something they already have.
3859
+ setEditingPerspectiveId(null);
3860
+ setConfigureBaseView(true);
3861
+ setConfigPanelInitialSection(null);
3862
+ setConfigPanelOpen(true);
3863
+ }}
3864
+ onBaseViewReset={handleBaseViewReset}
3865
+ onAddPerspective={() => { setEditingPerspectiveId(null); setConfigureBaseView(false); setConfigPanelInitialSection(null); setConfigPanelOpen(true); }}
3866
+ />
3867
+ )}
3868
+ <div className="hot-tabs-row-spacer" />
3869
+ {pagination && !hidePagination && (
3870
+ <TablePagination pagination={pagination} />
3871
+ )}
3872
+ </div>
3873
+ )}
3874
+
3875
+ {/* HEDGE-165 — the failed load that still has rows to show.
3876
+ `LoadErrorState` below is gated on `rowCount === 0`, so until this bar
3877
+ existed `loadError === true && rowCount > 0` rendered NOTHING: the
3878
+ rows `placeholderData` is holding stayed on screen with no indication
3879
+ that they answer the previous request rather than the current one.
3880
+ Sits above the column headers so it is visible wherever the body is
3881
+ scrolled to. Mounted only in that state — nothing on the healthy path
3882
+ moves, and no table pays for a permanently-mounted live region. */}
3883
+ {loadError && rowCount > 0 && (
3884
+ <StaleDataBar tableName={tableName} onRetry={onRetryLoad} />
3885
+ )}
3886
+
3887
+ {/* Grouped-actions bar — shown whenever ≥1 row is selected (Figma 546:12289).
3888
+ Sits between the perspective tabs and the column headers. Kept mounted
3889
+ (not conditionally rendered) so it can animate open/closed via the
3890
+ grid-rows collapse on the wrapper — avoids the hard layout shift.
3891
+ `bulkBarCountRef` freezes the last visible count so the text doesn't
3892
+ flash "0 selected" mid-collapse. */}
3893
+ {selectable && (() => {
3894
+ const bulkBarOpen = selectedRowIds.size >= 1;
3895
+ if (bulkBarOpen) bulkBarCountRef.current = selectedRowIds.size;
3896
+ return (
3897
+ <div
3898
+ className={`hot-bulk-bar-wrap${bulkBarOpen ? ' is-open' : ''}`}
3899
+ aria-hidden={!bulkBarOpen}
3900
+ >
3901
+ <div className="hot-bulk-bar-inner">
3902
+ <BulkActionsBar
3903
+ count={bulkBarOpen ? selectedRowIds.size : bulkBarCountRef.current}
3904
+ busy={isBulkDeleting}
3905
+ onCopy={handleBulkCopy}
3906
+ onDelete={onBulkDelete ? handleBulkDelete : undefined}
3907
+ onExport={hideExportButton ? undefined : handleExportSelection}
3908
+ onClear={clearSelection}
3909
+ actions={bulkActions}
3910
+ selectedIds={Array.from(selectedRowIds)}
3911
+ />
3912
+ </div>
3913
+ </div>
3914
+ );
3915
+ })()}
3916
+
3917
+ {/* A full-width band between the toolbar and the body — the folder row
3918
+ in the documents module. Rendered for BOTH view modes. */}
3919
+ {beforeBody ? (
3920
+ <div className="hot-before-body">
3921
+ {typeof beforeBody === 'function'
3922
+ ? beforeBody({ data, filters, setFilters: handleFiltersChange })
3923
+ : beforeBody}
3924
+ </div>
3925
+ ) : null}
3926
+
3927
+ {/* Table Container */}
3928
+ <div
3929
+ ref={tableRef}
3930
+ /* The spreadsheet container is focusable so it can receive Escape and
3931
+ the arrow keys. A tile grid's tiles are the focusable things. */
3932
+ tabIndex={usingCustomBody ? undefined : 0}
3933
+ data-fill-enabled={!usingCustomBody && enableFillHandle ? 'true' : undefined}
3934
+ /* P5 — the density guardrail. The perf harness reads these when
3935
+ present and falls back to counting mounted <th>s when they are
3936
+ absent, so WITHOUT them a successful column virtualization reports
3937
+ as a "57 → 21 column loss", i.e. exactly the regression the product
3938
+ owner forbade. They must never be derived from the mounted window. */
3939
+ data-dt-columns={cols.length}
3940
+ data-dt-rows={data.length}
3941
+ data-column-window={`${columnVirtualizer.startIndex}:${columnVirtualizer.endIndex}`}
3942
+ data-column-virtualized={columnVirtualizer.virtualized ? 'true' : undefined}
3943
+ /* aria-colcount counts ALL columns, never the mounted subset — a grid
3944
+ whose rendered cells are a subset is required to declare it, which is
3945
+ what makes the per-cell aria-colindex meaningful. */
3946
+ /* An alternative body is not a grid of cells. Announcing one — with a
3947
+ column count and a row count that address nothing on screen — is
3948
+ worse than announcing nothing. */
3949
+ role={usingCustomBody ? 'presentation' : 'grid'}
3950
+ aria-colcount={usingCustomBody ? undefined : columnVirtualizer.ariaColCount}
3951
+ aria-rowcount={usingCustomBody ? undefined : virtualizerCount}
3952
+ className={`hot-virtual-container ${shouldFillHeight ? 'flex-1' : ''}${usingCustomBody ? ' hot-custom-body' : ''}`}
3953
+ onFocus={usingCustomBody ? undefined : handleFocus}
3954
+ onBlur={usingCustomBody ? undefined : handleBlur}
3955
+ onClick={usingCustomBody ? undefined : (e) => {
3956
+ handleTableClick(e);
3957
+ }}
3958
+ onMouseDown={usingCustomBody ? undefined : (e) => {
3959
+ // Skip normal mouse handlers on group header rows
3960
+ if ((e.target as HTMLElement).closest('tr[data-group-header]')) return;
3961
+ // Comment handler runs first — if it opens the dialog, skip normal mousedown
3962
+ if (handleCommentMouseDown(e)) return;
3963
+ // Fill handle: a drag starting on the corner nub fills instead of
3964
+ // selecting. Leaves the source selection intact; skips normal mousedown.
3965
+ if (handleFillMouseDown(e)) return;
3966
+ // A new drag starts here: drop any committed outline + selection menu so
3967
+ // the dotted border only re-appears once THIS drag finishes.
3968
+ if (rangePhaseRef.current !== 'idle') applyRangePhase('idle');
3969
+ setContextMenu(null);
3970
+ handleMouseDown(e);
3971
+ // Focus the table container so it can receive keyboard events (e.g., Escape)
3972
+ // But not when clicking inside the currently editing cell — that would steal
3973
+ // focus from the editor input, killing the edit session.
3974
+ const editingCell = store.getEditingCell();
3975
+ if (editingCell) {
3976
+ const cell = (e.target as HTMLElement).closest('td');
3977
+ const clickRow = parseInt(cell?.getAttribute('data-row') || '', 10);
3978
+ const clickCol = parseInt(cell?.getAttribute('data-col') || '', 10);
3979
+ if (clickRow === editingCell.row && clickCol === editingCell.col) {
3980
+ return;
3981
+ }
3982
+ }
3983
+ // preventScroll: focusing this tabIndex=0 scroll container would
3984
+ // otherwise make the browser scroll it fully into view. When the
3985
+ // table sits partly below the fold (e.g. a banner above it pushing
3986
+ // it down), that scroll fires on the first mousedown and shifts the
3987
+ // page out from under the cursor — so the click misses its target
3988
+ // and the user has to click a second time. See cross-table focus
3989
+ // forwarding which already relies on explicit scroll control.
3990
+ tableRef.current?.focus({ preventScroll: true });
3991
+ }}
3992
+ onMouseMove={usingCustomBody ? undefined : (e) => {
3993
+ if (fillStateRef.current) { handleFillMouseMove(e); return; }
3994
+ handleMouseMove(e);
3995
+ }}
3996
+ onMouseUp={usingCustomBody ? undefined : () => {
3997
+ if (fillStateRef.current) { commitFill(); return; }
3998
+ handleMouseUpWithMenu();
3999
+ }}
4000
+ onMouseOver={usingCustomBody ? undefined : (e) => { handleCellHoverOver(e); handleCommentHoverOver(e); }}
4001
+ onMouseOut={usingCustomBody ? undefined : handleCommentHoverOut}
4002
+ onMouseLeave={usingCustomBody ? undefined : () => store.setHoveredCell(null)}
4003
+ onDoubleClick={usingCustomBody ? undefined : handleDoubleClick}
4004
+ onKeyDown={usingCustomBody ? undefined : handleKeyDown}
4005
+ style={{
4006
+ height: isFullscreen ? 'calc(100% - 90px)' : (shouldFillHeight ? undefined : (typeof height === 'string' && height !== 'auto' ? height : '600px')),
4007
+ overflow: 'auto',
4008
+ position: 'relative',
4009
+ outline: 'none',
4010
+ ...(shouldFillHeight && { minHeight: 0 }),
4011
+ }}
4012
+ >
4013
+ {usingCustomBody ? (
4014
+ renderBody!({
4015
+ data,
4016
+ columns: cols,
4017
+ visualRows: groupingResult.visualRows,
4018
+ groupRules,
4019
+ collapsedGroups: groupingResult.collapsedGroups,
4020
+ toggleGroup: groupingResult.toggleGroup,
4021
+ selectedRowIds,
4022
+ rowKeyColumn: selectionColumnName,
4023
+ selectable,
4024
+ toggleRowSelect: handleToggleRowSelect,
4025
+ toggleGroupSelect: handleToggleGroupSelect,
4026
+ clearSelection,
4027
+ searchQuery,
4028
+ filters,
4029
+ setFilters: handleFiltersChange,
4030
+ filterCount: filters.length,
4031
+ clearSearch: handleClearSearch,
4032
+ clearFilters: handleFiltersChange ? () => handleFiltersChange([]) : undefined,
4033
+ })
4034
+ ) : (
4035
+ <>
4036
+ {/* Column Headers */}
4037
+ {colHeaders && (
4038
+ <ColumnHeaders
4039
+ columns={cols}
4040
+ rowHeaders={rowHeaders}
4041
+ leftOffsets={effectiveLeftOffsets}
4042
+ rightOffsets={rightOffsets}
4043
+ totalWidth={totalWidth}
4044
+ sortState={sortState}
4045
+ actionsColumnWidth={actionsColumnWidth}
4046
+ showActionsColumn={showActionsColumn}
4047
+ onSort={handleColumnSort}
4048
+ onResizeStart={(e, colIndex) => { userResizedColsRef.current.add(colIndex); handleResizeStart(e, colIndex); }}
4049
+ onDoubleClick={handleColumnHeaderDoubleClick}
4050
+ onMouseDown={(e) => handleColumnHeaderMouseDown(e, dragHandlers.handleDragStart)}
4051
+ onMouseMove={handleMouseMove}
4052
+ modernLayout={!disableBuiltinColumnMenu}
4053
+ onSortAsc={handleModernSortAsc}
4054
+ onSortDesc={handleModernSortDesc}
4055
+ onFilterByField={handleModernFilterByField}
4056
+ /* A1 — the header quick filter writes straight into the same
4057
+ ephemeral `filters` state the Configure View drawer edits.
4058
+ Nothing extra persists it, which is the A2/D6 guarantee. */
4059
+ filters={filters}
4060
+ onFiltersChange={handleFiltersChange}
4061
+ loadFilterSuggestions={loadFilterSuggestions}
4062
+ onFreezeToggle={handleModernFreezeToggle}
4063
+ onHideField={handleModernHideField}
4064
+ /* A16 — reorder from the header itself, not only from the
4065
+ Configure View drawer. Per-user, like every other view
4066
+ preference. */
4067
+ onColumnReorder={handleColumnReorder}
4068
+ onColumnMove={handleColumnMove}
4069
+ frozenColumns={frozenColumns}
4070
+ lastFrozenColumnKey={lastFrozenColumnKey}
4071
+ columnActions={columnActions}
4072
+ onColumnAction={handleModernColumnAction}
4073
+ selectable={selectable}
4074
+ allSelected={allSelected}
4075
+ someSelected={someSelected}
4076
+ onToggleSelectAll={handleToggleSelectAll}
4077
+ columnWindow={columnVirtualizer}
4078
+ />
4079
+ )}
4080
+
4081
+ {/* Virtual Body */}
4082
+ <table className="hot-table" style={{ width: `${totalWidth}px` }}>
4083
+ <tbody
4084
+ style={{
4085
+ display: 'block',
4086
+ height: `${rowVirtualizer.getTotalSize()}px`,
4087
+ position: 'relative',
4088
+ }}
4089
+ >
4090
+ {virtualRows.map((virtualItem) => {
4091
+ // When grouped, dispatch between GroupHeaderRow and VirtualRow
4092
+ if (isGrouped) {
4093
+ const vr = groupingResult.visualRows![virtualItem.index];
4094
+ if (vr.type === 'groupHeader') {
4095
+ const groupRowIds = vr.memberDataIndices
4096
+ .map((idx) => {
4097
+ const r = data[idx];
4098
+ return r == null ? undefined : r[selectionColumnName];
4099
+ })
4100
+ .filter((id) => id != null)
4101
+ .map((id) => String(id));
4102
+ const groupSelected = groupRowIds.length > 0 && groupRowIds.every((id) => selectedRowIds.has(id));
4103
+ const groupSomeSelected = !groupSelected && groupRowIds.some((id) => selectedRowIds.has(id));
4104
+ return (
4105
+ <GroupHeaderRow
4106
+ key={`gh-${vr.groupKey}`}
4107
+ visualRow={vr}
4108
+ virtualItem={virtualItem}
4109
+ totalWidth={totalWidth}
4110
+ columns={cols}
4111
+ onToggle={groupingResult.toggleGroup}
4112
+ selectable={selectable}
4113
+ selected={groupSelected}
4114
+ someSelected={groupSomeSelected}
4115
+ onToggleSelect={() => handleToggleGroupSelect(groupRowIds)}
4116
+ formatGroupValue={formatGroupValue}
4117
+ />
4118
+ );
4119
+ }
4120
+ if (vr.type === 'groupSummary') {
4121
+ return (
4122
+ <GroupSummaryRow
4123
+ key={`gs-${vr.groupKey}`}
4124
+ visualRow={vr}
4125
+ virtualItem={virtualItem}
4126
+ columns={cols}
4127
+ rowHeaders={rowHeaders}
4128
+ leftOffsets={effectiveLeftOffsets}
4129
+ rightOffsets={rightOffsets}
4130
+ actionsColumnWidth={actionsColumnWidth}
4131
+ showActionsColumn={showActionsColumn}
4132
+ totalWidth={totalWidth}
4133
+ aggregations={aggregations}
4134
+ label={t('dynamicTable.aggregation.summaryRowLabel', 'Sum')}
4135
+ locale={locale}
4136
+ storeRevision={storeRevision}
4137
+ columnWindow={columnVirtualizer}
4138
+ />
4139
+ );
4140
+ }
4141
+ // dataRow — use dataIndex for the store
4142
+ return (
4143
+ <VirtualRow
4144
+ key={`dr-${vr.dataIndex}`}
4145
+ rowIndex={vr.dataIndex}
4146
+ columns={cols}
4147
+ virtualRow={virtualItem}
4148
+ rowHeaders={rowHeaders}
4149
+ leftOffsets={effectiveLeftOffsets}
4150
+ lastFrozenColumnKey={lastFrozenColumnKey}
4151
+ rightOffsets={rightOffsets}
4152
+ actionsColumnWidth={actionsColumnWidth}
4153
+ showActionsColumn={showActionsColumn}
4154
+ totalWidth={totalWidth}
4155
+ storeRevision={storeRevision}
4156
+ onSaveNewRow={handleSaveNewRow}
4157
+ onCancelNewRow={handleCancelNewRow}
4158
+ onRowHeaderDoubleClick={handleRowHeaderDoubleClick}
4159
+ onCellSave={handleCellSave}
4160
+ onCellContextMenu={handleCellContextMenu}
4161
+ actionsRenderer={actionsRenderer}
4162
+ showRowActionsMenu={showRowActionsMenu}
4163
+ rowActions={rowActions}
4164
+ onRowActionsMenu={handleRowActionsMenu}
4165
+ highlightedRowId={highlightedRowId}
4166
+ idColumnName={idColumnName}
4167
+ rowKeyColumn={selectionColumnName}
4168
+ annotations={enableComments ? annotations : undefined}
4169
+ commentsEnabled={enableComments && !!commentsEntityType}
4170
+ annotationKeyAt={enableComments ? cellAnnotationKeyAt : undefined}
4171
+ selectable={selectable}
4172
+ selectedRowIds={selectedRowIds}
4173
+ onToggleRowSelect={handleToggleRowSelect}
4174
+ compiledFormats={compiledFormats}
4175
+ columnWindow={columnVirtualizer}
4176
+ />
4177
+ );
4178
+ }
4179
+
4180
+ // Not grouped — standard rendering
4181
+ return (
4182
+ <VirtualRow
4183
+ key={virtualItem.index}
4184
+ rowIndex={virtualItem.index}
4185
+ columns={cols}
4186
+ virtualRow={virtualItem}
4187
+ rowHeaders={rowHeaders}
4188
+ leftOffsets={effectiveLeftOffsets}
4189
+ lastFrozenColumnKey={lastFrozenColumnKey}
4190
+ rightOffsets={rightOffsets}
4191
+ actionsColumnWidth={actionsColumnWidth}
4192
+ showActionsColumn={showActionsColumn}
4193
+ totalWidth={totalWidth}
4194
+ storeRevision={storeRevision}
4195
+ onSaveNewRow={handleSaveNewRow}
4196
+ onCancelNewRow={handleCancelNewRow}
4197
+ onRowHeaderDoubleClick={handleRowHeaderDoubleClick}
4198
+ onCellSave={handleCellSave}
4199
+ onCellContextMenu={handleCellContextMenu}
4200
+ actionsRenderer={actionsRenderer}
4201
+ showRowActionsMenu={showRowActionsMenu}
4202
+ rowActions={rowActions}
4203
+ onRowActionsMenu={handleRowActionsMenu}
4204
+ highlightedRowId={highlightedRowId}
4205
+ idColumnName={idColumnName}
4206
+ rowKeyColumn={selectionColumnName}
4207
+ annotations={enableComments ? annotations : undefined}
4208
+ commentsEnabled={enableComments && !!commentsEntityType}
4209
+ annotationKeyAt={enableComments ? cellAnnotationKeyAt : undefined}
4210
+ selectable={selectable}
4211
+ selectedRowIds={selectedRowIds}
4212
+ onToggleRowSelect={handleToggleRowSelect}
4213
+ compiledFormats={compiledFormats}
4214
+ columnWindow={columnVirtualizer}
4215
+ />
4216
+ );
4217
+ })}
4218
+ </tbody>
4219
+ </table>
4220
+
4221
+ {/* Ledger 2.20 — the zero-result state.
4222
+ A search that matches nothing used to leave the header row and
4223
+ several hundred pixels of nothing: no message, no icon, and no
4224
+ way back other than finding the search box again and emptying it
4225
+ by hand. The headers STAY (they are the context that tells you
4226
+ which table you emptied); the panel sits under them, says which
4227
+ of the three reasons applies, and offers the undo for that
4228
+ reason. */}
4229
+ {/* HEDGE-119 — a FAILED load is not an empty table.
4230
+ `loadError` wins over `EmptyState` because both arrive here as
4231
+ `rowCount === 0`: a rejected query leaves `data` undefined and
4232
+ the grid holds zero rows either way. Deciding by row count
4233
+ alone is what made a 500 read as "Nothing here yet". */}
4234
+ {rowCount === 0 && (loadError
4235
+ ? <LoadErrorState tableName={tableName} onRetry={onRetryLoad} />
4236
+ : <EmptyState
4237
+ emptyMessage={emptyMessage}
4238
+ searchQuery={searchQuery}
4239
+ filterCount={filters.length}
4240
+ onClearSearch={handleClearSearch}
4241
+ onClearFilters={handleFiltersChange ? () => handleFiltersChange([]) : undefined}
4242
+ />)}
4243
+
4244
+ {/* Pinned totals. Rendered OUTSIDE the virtualiser deliberately: a
4245
+ row inside the rows container scrolls away with them, and a
4246
+ footer that scrolls away is not a footer. It is sticky to the
4247
+ bottom of this scroller exactly as the header is to the top. */}
4248
+ {aggregations.length > 0 && (
4249
+ <FooterTotalsRow
4250
+ columns={cols}
4251
+ aggregations={aggregations}
4252
+ result={aggregateResult}
4253
+ pageValues={pageAggregateValues}
4254
+ pageBreakdowns={pageAggregateBreakdowns}
4255
+ pageRowCount={data.length}
4256
+ loading={aggregateLoading}
4257
+ error={aggregateError}
4258
+ getColumnWidth={getColumnWidth}
4259
+ rowHeaders={rowHeaders}
4260
+ leftOffsets={effectiveLeftOffsets}
4261
+ rightOffsets={rightOffsets}
4262
+ actionsColumnWidth={actionsColumnWidth}
4263
+ showActionsColumn={showActionsColumn}
4264
+ totalWidth={totalWidth}
4265
+ locale={locale}
4266
+ columnWindow={columnVirtualizer}
4267
+ />
4268
+ )}
4269
+ </>
4270
+ )}
4271
+ </div>
4272
+
4273
+ </div>{/* /hot-card */}
4274
+
4275
+ {/* Configure View Side Panel */}
4276
+ {(
4277
+ <ConfigureViewPanel
4278
+ isOpen={configPanelOpen}
4279
+ onOpenChange={setConfigPanelOpen}
4280
+ columns={baseColumns}
4281
+ visibleColumns={visibleColumns}
4282
+ hiddenColumns={hiddenColumns}
4283
+ filters={filters}
4284
+ sortRules={sortRules}
4285
+ groupRules={groupRules}
4286
+ aggregations={aggregations}
4287
+ lookupColumns={lookupColumns}
4288
+ rollupColumns={rollupColumns}
4289
+ formulas={formulas}
4290
+ conditionalFormats={conditionalFormats}
4291
+ sampleRow={data[0]}
4292
+ dateFormat={dateFormat}
4293
+ viewMode={viewMode}
4294
+ dateFormatOptions={dateFormatOptions}
4295
+ onColumnVisibilityChange={handleColumnVisibilityChange}
4296
+ onFiltersChange={handleFiltersChange}
4297
+ onSortRulesChange={handleSortRulesChange}
4298
+ onGroupRulesChange={handleGroupRulesChange}
4299
+ onAggregationsChange={handleAggregationsChange}
4300
+ onLookupColumnsChange={handleLookupColumnsChange}
4301
+ onRollupColumnsChange={handleRollupColumnsChange}
4302
+ onFormulasChange={handleFormulasChange}
4303
+ onConditionalFormatsChange={handleConditionalFormatsChange}
4304
+ onDateFormatChange={handleDateFormatChange}
4305
+ onSavePerspective={handleSavePerspective}
4306
+ activePerspectiveId={activePerspectiveId}
4307
+ editingPerspective={savedPerspectives.find((p) => p.id === editingPerspectiveId) ?? null}
4308
+ baseViewPerspective={baseViewPerspective}
4309
+ isBaseViewMode={configureBaseView}
4310
+ loadFilterSuggestions={loadFilterSuggestions}
4311
+ loadLookupSources={loadLookupSources}
4312
+ loadRollupSources={loadRollupSources}
4313
+ initialExpandedSection={configPanelInitialSection}
4314
+ />
4315
+ )}
4316
+
4317
+ {/* Large-write confirmation — every written cell triggers a persist
4318
+ request, so the same dialog guards a big fill, paste, cut and clear.
4319
+ The title names the gesture: "Fill cells?" on a Delete would read as
4320
+ the wrong operation about to run. */}
4321
+ <Dialog open={!!pendingWrite} onOpenChange={(open) => { if (!open) cancelPendingWrite(); }}>
4322
+ <DialogContent className="hot-dialog">
4323
+ <DialogHeader>
4324
+ <DialogTitle>
4325
+ {pendingWrite?.origin === 'paste'
4326
+ ? t('dynamicTable.paste.confirmTitle', 'Paste into these cells?')
4327
+ : pendingWrite?.origin === 'cut'
4328
+ ? t('dynamicTable.cut.confirmTitle', 'Cut these cells?')
4329
+ : pendingWrite?.origin === 'clear'
4330
+ ? t('dynamicTable.clear.confirmTitle', 'Clear these cells?')
4331
+ : t('dynamicTable.fill.confirmTitle', 'Fill cells?')}
4332
+ </DialogTitle>
4333
+ <DialogDescription>
4334
+ {t('dynamicTable.write.confirmBody', 'This will overwrite {count} cells.', {
4335
+ count: pendingWrite?.writes.length ?? 0,
4336
+ })}
4337
+ </DialogDescription>
4338
+ </DialogHeader>
4339
+ <DialogFooter>
4340
+ <Button variant="outline" onClick={cancelPendingWrite}>{t('dynamicTable.fill.cancel', 'Cancel')}</Button>
4341
+ <Button onClick={confirmPendingWrite}>
4342
+ {pendingWrite?.origin === 'paste'
4343
+ ? t('dynamicTable.paste.confirm', 'Paste')
4344
+ : pendingWrite?.origin === 'cut'
4345
+ ? t('dynamicTable.cut.confirm', 'Cut')
4346
+ : pendingWrite?.origin === 'clear'
4347
+ ? t('dynamicTable.clear.confirm', 'Clear')
4348
+ : t('dynamicTable.fill.confirm', 'Fill')}
4349
+ </Button>
4350
+ </DialogFooter>
4351
+ </DialogContent>
4352
+ </Dialog>
4353
+
4354
+ {/* Cell Comment Dialog */}
4355
+ {enableComments && commentsEntityType && commentDialog && (
4356
+ <CellCommentDialog
4357
+ isOpen={true}
4358
+ onClose={handleCommentDialogClose}
4359
+ /* Resolved when the dialog was opened, from the cell that opened it. */
4360
+ entityType={commentDialog.entityType}
4361
+ viewContext={commentsViewContext}
4362
+ rowId={commentDialog.rowId}
4363
+ columnKey={commentDialog.columnKey}
4364
+ columnTitle={commentDialog.columnTitle}
4365
+ rowLabel={commentDialog.rowLabel}
4366
+ annotationId={commentDialog.annotationId}
4367
+ currentColor={commentDialog.currentColor}
4368
+ onAnnotationChange={() => { refreshAnnotations(); onAnnotationChange?.(); }}
4369
+ anchorRect={commentDialog.anchorRect}
4370
+ bulkCells={commentDialog.bulkCells}
4371
+ />
4372
+ )}
4373
+
4374
+ {/* Cell Comment hover preview (read-only) — suppressed while the editable
4375
+ dialog is open. */}
4376
+ {enableComments && commentsEntityType && commentHover && !commentDialog && (
4377
+ <CellCommentHoverPopup
4378
+ comments={commentHover.comments}
4379
+ columnTitle={commentHover.columnTitle}
4380
+ anchorRect={commentHover.anchorRect}
4381
+ />
4382
+ )}
4383
+
4384
+ {/* Context Menu */}
4385
+ {contextMenu && (
4386
+ <ContextMenu
4387
+ isOpen={contextMenu.isOpen}
4388
+ position={contextMenu.position}
4389
+ actions={contextMenu.actions}
4390
+ onClose={handleContextMenuClose}
4391
+ onActionClick={handleMenuActionClick}
4392
+ />
4393
+ )}
4394
+
4395
+ {/* Debugger */}
4396
+ {debug && <Debugger tableRef={tableRef} />}
4397
+ </div>
4398
+ );
4399
+
4400
+ return (
4401
+ <TableDateFormatContext.Provider value={dateFormat}>
4402
+ <CellStoreContext.Provider value={store}>
4403
+ {isFullscreen ? (
4404
+ <FullscreenOverlay
4405
+ isOpen={isFullscreen}
4406
+ onClose={handleExitFullscreen}
4407
+ tableName={displayTableName}
4408
+ >
4409
+ {tableContent}
4410
+ </FullscreenOverlay>
4411
+ ) : (
4412
+ tableContent
4413
+ )}
4414
+ </CellStoreContext.Provider>
4415
+ </TableDateFormatContext.Provider>
4416
+ );
4417
+ };
4418
+
4419
+ export default DynamicTable;