@multiplatform.one/frappe-ui 7.28.1 → 7.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) hide show
  1. package/dist/cjs/FrappeCalendar.cjs +3 -2
  2. package/dist/cjs/FrappeCalendar.native.js +3 -2
  3. package/dist/cjs/FrappeCalendar.native.js.map +1 -1
  4. package/dist/cjs/FrappeForm.cjs +148 -464
  5. package/dist/cjs/FrappeForm.native.js +152 -633
  6. package/dist/cjs/FrappeForm.native.js.map +1 -1
  7. package/dist/cjs/FrappeListView.cjs +5 -27
  8. package/dist/cjs/FrappeListView.native.js +11 -32
  9. package/dist/cjs/FrappeListView.native.js.map +1 -1
  10. package/dist/cjs/FrappeReportBuilder.cjs +523 -309
  11. package/dist/cjs/FrappeReportBuilder.native.js +658 -353
  12. package/dist/cjs/FrappeReportBuilder.native.js.map +1 -1
  13. package/dist/cjs/FrappeReportView.cjs +35 -234
  14. package/dist/cjs/FrappeReportView.native.js +39 -283
  15. package/dist/cjs/FrappeReportView.native.js.map +1 -1
  16. package/dist/cjs/dependsOn.cjs +2 -2
  17. package/dist/cjs/dependsOn.native.js +1 -2
  18. package/dist/cjs/dependsOn.native.js.map +1 -1
  19. package/dist/cjs/fields/shared/EditorPreviewPanes.cjs +2 -2
  20. package/dist/cjs/fields/shared/EditorPreviewPanes.native.js +2 -2
  21. package/dist/cjs/fields/shared/EditorPreviewPanes.native.js.map +1 -1
  22. package/dist/cjs/formEngine/docPermissions.cjs +20 -0
  23. package/dist/cjs/formEngine/docPermissions.native.js +69 -0
  24. package/dist/cjs/formEngine/docPermissions.native.js.map +1 -1
  25. package/dist/cjs/formEngine/index.cjs +2 -0
  26. package/dist/cjs/formEngine/index.native.js +2 -0
  27. package/dist/cjs/formEngine/index.native.js.map +1 -1
  28. package/dist/cjs/index.cjs +67 -7
  29. package/dist/cjs/index.native.js +67 -7
  30. package/dist/cjs/index.native.js.map +1 -1
  31. package/dist/cjs/liveErrors.cjs +8 -2
  32. package/dist/cjs/liveErrors.native.js +8 -2
  33. package/dist/cjs/liveErrors.native.js.map +1 -1
  34. package/dist/cjs/measures.cjs +31 -0
  35. package/dist/cjs/measures.native.js +34 -0
  36. package/dist/cjs/measures.native.js.map +1 -0
  37. package/dist/cjs/recordShape.cjs +103 -0
  38. package/dist/cjs/recordShape.native.js +115 -0
  39. package/dist/cjs/recordShape.native.js.map +1 -0
  40. package/dist/cjs/report/ReportGrid.cjs +453 -0
  41. package/dist/cjs/report/ReportGrid.native.js +510 -0
  42. package/dist/cjs/report/ReportGrid.native.js.map +1 -0
  43. package/dist/cjs/report/ReportViewToolbar.cjs +253 -0
  44. package/dist/cjs/report/ReportViewToolbar.native.js +303 -0
  45. package/dist/cjs/report/ReportViewToolbar.native.js.map +1 -0
  46. package/dist/cjs/report/queryReport.cjs +444 -0
  47. package/dist/cjs/report/queryReport.native.js +588 -0
  48. package/dist/cjs/report/queryReport.native.js.map +1 -0
  49. package/dist/cjs/report/queryReportColumns.cjs +131 -0
  50. package/dist/cjs/report/queryReportColumns.native.js +159 -0
  51. package/dist/cjs/report/queryReportColumns.native.js.map +1 -0
  52. package/dist/cjs/report/reportViewModel.cjs +386 -0
  53. package/dist/cjs/report/reportViewModel.native.js +657 -0
  54. package/dist/cjs/report/reportViewModel.native.js.map +1 -0
  55. package/dist/cjs/report/savedReports.cjs +137 -0
  56. package/dist/cjs/report/savedReports.native.js +169 -0
  57. package/dist/cjs/report/savedReports.native.js.map +1 -0
  58. package/dist/cjs/report/useFrappeReport.cjs +184 -0
  59. package/dist/cjs/report/useFrappeReport.native.js +214 -0
  60. package/dist/cjs/report/useFrappeReport.native.js.map +1 -0
  61. package/dist/cjs/report/useQueryReport.cjs +201 -0
  62. package/dist/cjs/report/useQueryReport.native.js +233 -0
  63. package/dist/cjs/report/useQueryReport.native.js.map +1 -0
  64. package/dist/cjs/useFrappeForm.cjs +558 -0
  65. package/dist/cjs/useFrappeForm.native.js +714 -0
  66. package/dist/cjs/useFrappeForm.native.js.map +1 -0
  67. package/dist/esm/FrappeCalendar.mjs +4 -3
  68. package/dist/esm/FrappeCalendar.mjs.map +1 -1
  69. package/dist/esm/FrappeCalendar.native.js +4 -3
  70. package/dist/esm/FrappeCalendar.native.js.map +1 -1
  71. package/dist/esm/FrappeForm.mjs +151 -467
  72. package/dist/esm/FrappeForm.mjs.map +1 -1
  73. package/dist/esm/FrappeForm.native.js +155 -636
  74. package/dist/esm/FrappeForm.native.js.map +1 -1
  75. package/dist/esm/FrappeListView.mjs +6 -28
  76. package/dist/esm/FrappeListView.mjs.map +1 -1
  77. package/dist/esm/FrappeListView.native.js +12 -33
  78. package/dist/esm/FrappeListView.native.js.map +1 -1
  79. package/dist/esm/FrappeReportBuilder.mjs +526 -312
  80. package/dist/esm/FrappeReportBuilder.mjs.map +1 -1
  81. package/dist/esm/FrappeReportBuilder.native.js +661 -356
  82. package/dist/esm/FrappeReportBuilder.native.js.map +1 -1
  83. package/dist/esm/FrappeReportView.mjs +30 -229
  84. package/dist/esm/FrappeReportView.mjs.map +1 -1
  85. package/dist/esm/FrappeReportView.native.js +34 -278
  86. package/dist/esm/FrappeReportView.native.js.map +1 -1
  87. package/dist/esm/dependsOn.mjs +2 -2
  88. package/dist/esm/dependsOn.mjs.map +1 -1
  89. package/dist/esm/dependsOn.native.js +1 -2
  90. package/dist/esm/dependsOn.native.js.map +1 -1
  91. package/dist/esm/fields/shared/EditorPreviewPanes.mjs +2 -2
  92. package/dist/esm/fields/shared/EditorPreviewPanes.mjs.map +1 -1
  93. package/dist/esm/fields/shared/EditorPreviewPanes.native.js +2 -2
  94. package/dist/esm/fields/shared/EditorPreviewPanes.native.js.map +1 -1
  95. package/dist/esm/formEngine/docPermissions.mjs +19 -1
  96. package/dist/esm/formEngine/docPermissions.mjs.map +1 -1
  97. package/dist/esm/formEngine/docPermissions.native.js +68 -1
  98. package/dist/esm/formEngine/docPermissions.native.js.map +1 -1
  99. package/dist/esm/formEngine/index.mjs +2 -2
  100. package/dist/esm/formEngine/index.mjs.map +1 -1
  101. package/dist/esm/formEngine/index.native.js +2 -2
  102. package/dist/esm/formEngine/index.native.js.map +1 -1
  103. package/dist/esm/index.mjs +16 -4
  104. package/dist/esm/index.mjs.map +1 -1
  105. package/dist/esm/index.native.js +16 -4
  106. package/dist/esm/index.native.js.map +1 -1
  107. package/dist/esm/liveErrors.mjs +8 -3
  108. package/dist/esm/liveErrors.mjs.map +1 -1
  109. package/dist/esm/liveErrors.native.js +8 -3
  110. package/dist/esm/liveErrors.native.js.map +1 -1
  111. package/dist/esm/measures.mjs +5 -0
  112. package/dist/esm/measures.mjs.map +1 -0
  113. package/dist/esm/measures.native.js +5 -0
  114. package/dist/esm/measures.native.js.map +1 -0
  115. package/dist/esm/recordShape.mjs +74 -0
  116. package/dist/esm/recordShape.mjs.map +1 -0
  117. package/dist/esm/recordShape.native.js +83 -0
  118. package/dist/esm/recordShape.native.js.map +1 -0
  119. package/dist/esm/report/ReportGrid.mjs +428 -0
  120. package/dist/esm/report/ReportGrid.mjs.map +1 -0
  121. package/dist/esm/report/ReportGrid.native.js +471 -0
  122. package/dist/esm/report/ReportGrid.native.js.map +1 -0
  123. package/dist/esm/report/ReportViewToolbar.mjs +228 -0
  124. package/dist/esm/report/ReportViewToolbar.mjs.map +1 -0
  125. package/dist/esm/report/ReportViewToolbar.native.js +264 -0
  126. package/dist/esm/report/ReportViewToolbar.native.js.map +1 -0
  127. package/dist/esm/report/queryReport.mjs +404 -0
  128. package/dist/esm/report/queryReport.mjs.map +1 -0
  129. package/dist/esm/report/queryReport.native.js +545 -0
  130. package/dist/esm/report/queryReport.native.js.map +1 -0
  131. package/dist/esm/report/queryReportColumns.mjs +105 -0
  132. package/dist/esm/report/queryReportColumns.mjs.map +1 -0
  133. package/dist/esm/report/queryReportColumns.native.js +119 -0
  134. package/dist/esm/report/queryReportColumns.native.js.map +1 -0
  135. package/dist/esm/report/reportViewModel.mjs +343 -0
  136. package/dist/esm/report/reportViewModel.mjs.map +1 -0
  137. package/dist/esm/report/reportViewModel.native.js +611 -0
  138. package/dist/esm/report/reportViewModel.native.js.map +1 -0
  139. package/dist/esm/report/savedReports.mjs +112 -0
  140. package/dist/esm/report/savedReports.mjs.map +1 -0
  141. package/dist/esm/report/savedReports.native.js +141 -0
  142. package/dist/esm/report/savedReports.native.js.map +1 -0
  143. package/dist/esm/report/useFrappeReport.mjs +158 -0
  144. package/dist/esm/report/useFrappeReport.mjs.map +1 -0
  145. package/dist/esm/report/useFrappeReport.native.js +185 -0
  146. package/dist/esm/report/useFrappeReport.native.js.map +1 -0
  147. package/dist/esm/report/useQueryReport.mjs +176 -0
  148. package/dist/esm/report/useQueryReport.mjs.map +1 -0
  149. package/dist/esm/report/useQueryReport.native.js +205 -0
  150. package/dist/esm/report/useQueryReport.native.js.map +1 -0
  151. package/dist/esm/useFrappeForm.mjs +533 -0
  152. package/dist/esm/useFrappeForm.mjs.map +1 -0
  153. package/dist/esm/useFrappeForm.native.js +686 -0
  154. package/dist/esm/useFrappeForm.native.js.map +1 -0
  155. package/dist/jsx/FrappeCalendar.mjs +4 -3
  156. package/dist/jsx/FrappeCalendar.mjs.map +1 -1
  157. package/dist/jsx/FrappeCalendar.native.js +3 -2
  158. package/dist/jsx/FrappeCalendar.native.js.map +1 -1
  159. package/dist/jsx/FrappeForm.mjs +151 -467
  160. package/dist/jsx/FrappeForm.mjs.map +1 -1
  161. package/dist/jsx/FrappeForm.native.js +152 -633
  162. package/dist/jsx/FrappeForm.native.js.map +1 -1
  163. package/dist/jsx/FrappeListView.mjs +6 -28
  164. package/dist/jsx/FrappeListView.mjs.map +1 -1
  165. package/dist/jsx/FrappeListView.native.js +11 -32
  166. package/dist/jsx/FrappeListView.native.js.map +1 -1
  167. package/dist/jsx/FrappeReportBuilder.mjs +526 -312
  168. package/dist/jsx/FrappeReportBuilder.mjs.map +1 -1
  169. package/dist/jsx/FrappeReportBuilder.native.js +658 -353
  170. package/dist/jsx/FrappeReportBuilder.native.js.map +1 -1
  171. package/dist/jsx/FrappeReportView.mjs +30 -229
  172. package/dist/jsx/FrappeReportView.mjs.map +1 -1
  173. package/dist/jsx/FrappeReportView.native.js +39 -283
  174. package/dist/jsx/FrappeReportView.native.js.map +1 -1
  175. package/dist/jsx/dependsOn.mjs +2 -2
  176. package/dist/jsx/dependsOn.mjs.map +1 -1
  177. package/dist/jsx/dependsOn.native.js +1 -2
  178. package/dist/jsx/dependsOn.native.js.map +1 -1
  179. package/dist/jsx/fields/shared/EditorPreviewPanes.mjs +2 -2
  180. package/dist/jsx/fields/shared/EditorPreviewPanes.mjs.map +1 -1
  181. package/dist/jsx/fields/shared/EditorPreviewPanes.native.js +2 -2
  182. package/dist/jsx/fields/shared/EditorPreviewPanes.native.js.map +1 -1
  183. package/dist/jsx/formEngine/docPermissions.mjs +19 -1
  184. package/dist/jsx/formEngine/docPermissions.mjs.map +1 -1
  185. package/dist/jsx/formEngine/docPermissions.native.js +69 -0
  186. package/dist/jsx/formEngine/docPermissions.native.js.map +1 -1
  187. package/dist/jsx/formEngine/index.mjs +2 -2
  188. package/dist/jsx/formEngine/index.mjs.map +1 -1
  189. package/dist/jsx/formEngine/index.native.js +2 -0
  190. package/dist/jsx/formEngine/index.native.js.map +1 -1
  191. package/dist/jsx/index.js +16 -4
  192. package/dist/jsx/index.js.map +1 -1
  193. package/dist/jsx/index.mjs +16 -4
  194. package/dist/jsx/index.mjs.map +1 -1
  195. package/dist/jsx/index.native.js +67 -7
  196. package/dist/jsx/index.native.js.map +1 -1
  197. package/dist/jsx/liveErrors.mjs +8 -3
  198. package/dist/jsx/liveErrors.mjs.map +1 -1
  199. package/dist/jsx/liveErrors.native.js +8 -2
  200. package/dist/jsx/liveErrors.native.js.map +1 -1
  201. package/dist/jsx/measures.mjs +5 -0
  202. package/dist/jsx/measures.mjs.map +1 -0
  203. package/dist/jsx/measures.native.js +34 -0
  204. package/dist/jsx/measures.native.js.map +1 -0
  205. package/dist/jsx/recordShape.mjs +74 -0
  206. package/dist/jsx/recordShape.mjs.map +1 -0
  207. package/dist/jsx/recordShape.native.js +115 -0
  208. package/dist/jsx/recordShape.native.js.map +1 -0
  209. package/dist/jsx/report/ReportGrid.mjs +428 -0
  210. package/dist/jsx/report/ReportGrid.mjs.map +1 -0
  211. package/dist/jsx/report/ReportGrid.native.js +510 -0
  212. package/dist/jsx/report/ReportGrid.native.js.map +1 -0
  213. package/dist/jsx/report/ReportViewToolbar.mjs +228 -0
  214. package/dist/jsx/report/ReportViewToolbar.mjs.map +1 -0
  215. package/dist/jsx/report/ReportViewToolbar.native.js +303 -0
  216. package/dist/jsx/report/ReportViewToolbar.native.js.map +1 -0
  217. package/dist/jsx/report/queryReport.mjs +404 -0
  218. package/dist/jsx/report/queryReport.mjs.map +1 -0
  219. package/dist/jsx/report/queryReport.native.js +588 -0
  220. package/dist/jsx/report/queryReport.native.js.map +1 -0
  221. package/dist/jsx/report/queryReportColumns.mjs +105 -0
  222. package/dist/jsx/report/queryReportColumns.mjs.map +1 -0
  223. package/dist/jsx/report/queryReportColumns.native.js +159 -0
  224. package/dist/jsx/report/queryReportColumns.native.js.map +1 -0
  225. package/dist/jsx/report/reportViewModel.mjs +343 -0
  226. package/dist/jsx/report/reportViewModel.mjs.map +1 -0
  227. package/dist/jsx/report/reportViewModel.native.js +657 -0
  228. package/dist/jsx/report/reportViewModel.native.js.map +1 -0
  229. package/dist/jsx/report/savedReports.mjs +112 -0
  230. package/dist/jsx/report/savedReports.mjs.map +1 -0
  231. package/dist/jsx/report/savedReports.native.js +169 -0
  232. package/dist/jsx/report/savedReports.native.js.map +1 -0
  233. package/dist/jsx/report/useFrappeReport.mjs +158 -0
  234. package/dist/jsx/report/useFrappeReport.mjs.map +1 -0
  235. package/dist/jsx/report/useFrappeReport.native.js +214 -0
  236. package/dist/jsx/report/useFrappeReport.native.js.map +1 -0
  237. package/dist/jsx/report/useQueryReport.mjs +176 -0
  238. package/dist/jsx/report/useQueryReport.mjs.map +1 -0
  239. package/dist/jsx/report/useQueryReport.native.js +233 -0
  240. package/dist/jsx/report/useQueryReport.native.js.map +1 -0
  241. package/dist/jsx/useFrappeForm.mjs +533 -0
  242. package/dist/jsx/useFrappeForm.mjs.map +1 -0
  243. package/dist/jsx/useFrappeForm.native.js +714 -0
  244. package/dist/jsx/useFrappeForm.native.js.map +1 -0
  245. package/package.json +16 -16
  246. package/src/FrappeCalendar.tsx +8 -2
  247. package/src/FrappeForm.stories.tsx +52 -0
  248. package/src/FrappeForm.tsx +176 -565
  249. package/src/FrappeListView.cards.stories.tsx +3 -6
  250. package/src/FrappeListView.tsx +15 -55
  251. package/src/FrappeReportBuilder.stories.tsx +34 -7
  252. package/src/FrappeReportBuilder.tsx +790 -380
  253. package/src/FrappeReportView.spec.tsx +10 -6
  254. package/src/FrappeReportView.stories.tsx +20 -1
  255. package/src/FrappeReportView.tsx +89 -415
  256. package/src/dependsOn.ts +4 -1
  257. package/src/fields/shared/EditorPreviewPanes.tsx +2 -2
  258. package/src/formEngine/docPermissions.spec.ts +36 -1
  259. package/src/formEngine/docPermissions.ts +34 -0
  260. package/src/formEngine/index.ts +8 -1
  261. package/src/frappeRemainder.designLaw.spec.ts +9 -1
  262. package/src/frappeRoutes.spec.tsx +9 -0
  263. package/src/frappeViews.designLaw.spec.ts +15 -2
  264. package/src/guestFallback.spec.tsx +236 -0
  265. package/src/index.ts +117 -16
  266. package/src/liveErrors.spec.ts +10 -0
  267. package/src/liveErrors.ts +16 -3
  268. package/src/measures.ts +11 -0
  269. package/src/recordShape.spec.ts +100 -0
  270. package/src/recordShape.ts +133 -0
  271. package/src/report/ReportGrid.spec.tsx +175 -0
  272. package/src/report/ReportGrid.tsx +530 -0
  273. package/src/report/ReportViewToolbar.spec.tsx +90 -0
  274. package/src/report/ReportViewToolbar.tsx +314 -0
  275. package/src/report/queryReport.spec.ts +483 -0
  276. package/src/report/queryReport.ts +722 -0
  277. package/src/report/queryReportColumns.tsx +123 -0
  278. package/src/report/reportViewModel.spec.ts +393 -0
  279. package/src/report/reportViewModel.ts +617 -0
  280. package/src/report/savedReports.ts +172 -0
  281. package/src/report/seam.spec.ts +216 -0
  282. package/src/report/useFrappeReport.ts +241 -0
  283. package/src/report/useQueryReport.spec.tsx +131 -0
  284. package/src/report/useQueryReport.ts +309 -0
  285. package/src/useFrappeForm.spec.tsx +209 -0
  286. package/src/useFrappeForm.ts +778 -0
  287. package/types/FrappeCalendar.d.ts.map +1 -1
  288. package/types/FrappeForm.d.ts +21 -1
  289. package/types/FrappeForm.d.ts.map +1 -1
  290. package/types/FrappeListView.d.ts.map +1 -1
  291. package/types/FrappeReportBuilder.d.ts +66 -16
  292. package/types/FrappeReportBuilder.d.ts.map +1 -1
  293. package/types/FrappeReportView.d.ts +9 -127
  294. package/types/FrappeReportView.d.ts.map +1 -1
  295. package/types/dependsOn.d.ts.map +1 -1
  296. package/types/formEngine/docPermissions.d.ts +14 -0
  297. package/types/formEngine/docPermissions.d.ts.map +1 -1
  298. package/types/formEngine/index.d.ts +1 -1
  299. package/types/formEngine/index.d.ts.map +1 -1
  300. package/types/index.d.ts +15 -3
  301. package/types/index.d.ts.map +1 -1
  302. package/types/liveErrors.d.ts +9 -1
  303. package/types/liveErrors.d.ts.map +1 -1
  304. package/types/measures.d.ts +3 -0
  305. package/types/measures.d.ts.map +1 -0
  306. package/types/recordShape.d.ts +45 -0
  307. package/types/recordShape.d.ts.map +1 -0
  308. package/types/report/ReportGrid.d.ts +20 -0
  309. package/types/report/ReportGrid.d.ts.map +1 -0
  310. package/types/report/ReportViewToolbar.d.ts +31 -0
  311. package/types/report/ReportViewToolbar.d.ts.map +1 -0
  312. package/types/report/queryReport.d.ts +181 -0
  313. package/types/report/queryReport.d.ts.map +1 -0
  314. package/types/report/queryReportColumns.d.ts +12 -0
  315. package/types/report/queryReportColumns.d.ts.map +1 -0
  316. package/types/report/reportViewModel.d.ts +146 -0
  317. package/types/report/reportViewModel.d.ts.map +1 -0
  318. package/types/report/savedReports.d.ts +27 -0
  319. package/types/report/savedReports.d.ts.map +1 -0
  320. package/types/report/useFrappeReport.d.ts +60 -0
  321. package/types/report/useFrappeReport.d.ts.map +1 -0
  322. package/types/report/useQueryReport.d.ts +37 -0
  323. package/types/report/useQueryReport.d.ts.map +1 -0
  324. package/types/useFrappeForm.d.ts +142 -0
  325. package/types/useFrappeForm.d.ts.map +1 -0
@@ -0,0 +1,530 @@
1
+ // Desk-parity report spreadsheet: row numbers, sortable column headers, a
2
+ // per-column FILTER ROW under the headers (desk's killer report feature —
3
+ // inputs drive the standard frappe filter pipeline via reportViewModel's
4
+ // kind→operator mapping), group header rows, and a totals row.
5
+ //
6
+ // Composed from @multiplatform.one/table's Table primitives (exported for
7
+ // standalone use) rather than DataTable: DataTable owns a single internal
8
+ // scrollport and wraps header content in Text, so it cannot host an input
9
+ // row under its headers today — capability gap flagged to the table
10
+ // workstream; this grid is the interim desk-parity surface.
11
+ import { ScrollView, Text, Tooltip, View, XStack, isWeb } from "@multiplatform.one/components";
12
+ import { Input } from "@multiplatform.one/forms";
13
+ import { Table } from "@multiplatform.one/table";
14
+ import {
15
+ ensureFocusVisibleRing,
16
+ formatCurrency,
17
+ formatNumber,
18
+ formatPercent,
19
+ } from "@multiplatform.one/theme";
20
+ import { CaretDownIcon, CaretUpDownIcon } from "@phosphor-icons/react";
21
+ import React, { useCallback, useState } from "react";
22
+ import { useTranslation } from "react-i18next";
23
+ import { formatDateCell } from "../list/listFormat";
24
+ import {
25
+ REPORT_NUMERIC_FIELDTYPES,
26
+ type ReportColumnDef,
27
+ type ReportGridRow,
28
+ type ReportRow,
29
+ type ReportSortState,
30
+ type ReportTotalsCell,
31
+ } from "./reportViewModel";
32
+
33
+ const ROW_NUMBER_WIDTH = 56;
34
+
35
+ // ── LC-49/LC-50 row keyboard contract ───────────────────────────────
36
+ // Local mirror of the table package's `isInteractiveInnerTarget`
37
+ // (public/table/src/utils/rowPress.ts — internal util, not on the
38
+ // barrel): when the whole row is one pressable unit, presses and keys
39
+ // that originate on inner interactive content (Link cells, future
40
+ // editors) must keep working without also firing the row press.
41
+ const INTERACTIVE_SELECTOR = [
42
+ "a[href]",
43
+ "button",
44
+ "input",
45
+ "select",
46
+ "textarea",
47
+ "label",
48
+ '[contenteditable="true"]',
49
+ '[role="button"]',
50
+ '[role="checkbox"]',
51
+ '[role="combobox"]',
52
+ '[role="link"]',
53
+ '[role="menuitem"]',
54
+ '[role="option"]',
55
+ '[role="radio"]',
56
+ '[role="slider"]',
57
+ '[role="spinbutton"]',
58
+ '[role="switch"]',
59
+ '[role="textbox"]',
60
+ "[aria-expanded]",
61
+ ].join(", ");
62
+
63
+ function isInteractiveInnerTarget(target: unknown, boundary: unknown): boolean {
64
+ if (typeof Element === "undefined") return false;
65
+ if (!(target instanceof Element)) return false;
66
+ const boundaryEl = boundary instanceof Element ? boundary : null;
67
+ let node: Element | null = target;
68
+ while (node && node !== boundaryEl) {
69
+ if (node.matches(INTERACTIVE_SELECTOR)) return true;
70
+ const tabIndex = node.getAttribute("tabindex");
71
+ // A tab stop of its own (0 or positive) marks a focusable control; the
72
+ // row's roving -1/0 tabindex lives on the boundary, not inside it.
73
+ if (tabIndex != null && Number(tabIndex) >= 0) return true;
74
+ node = node.parentElement;
75
+ }
76
+ return false;
77
+ }
78
+
79
+ export interface ReportGridProps {
80
+ columns: readonly ReportColumnDef[];
81
+ rows: readonly ReportGridRow[];
82
+ sort: ReportSortState;
83
+ onSortChange: (sort: ReportSortState) => void;
84
+ /** Filter-row DRAFT values by fieldname (uncommitted text). */
85
+ columnFilters: Readonly<Record<string, string>>;
86
+ onColumnFilterChange: (fieldname: string, value: string) => void;
87
+ /** Open the row's document form. Absent, rows are not pressable. */
88
+ onOpenRow?: (name: string) => void;
89
+ /** Open a Link cell's target document. */
90
+ onOpenLink?: (doctype: string, name: string) => void;
91
+ /** Totals over the full filtered result — rendered as a bottom row. */
92
+ totals: readonly ReportTotalsCell[];
93
+ /** Group-by column (group header rows span the grid). */
94
+ groupByColumn?: ReportColumnDef;
95
+ }
96
+
97
+ function alignToJustify(align: ReportColumnDef["align"]) {
98
+ return align === "right" ? "flex-end" : align === "center" ? "center" : "flex-start";
99
+ }
100
+
101
+ function formatNumericCell(value: number, fieldtype: string): string {
102
+ switch (fieldtype) {
103
+ case "Currency":
104
+ return formatCurrency(value);
105
+ case "Int":
106
+ return formatNumber(Math.round(value), { maximumFractionDigits: 0 });
107
+ case "Percent":
108
+ return formatPercent(value, { precision: 1 });
109
+ default:
110
+ return formatNumber(value);
111
+ }
112
+ }
113
+
114
+ /** Fieldtype-aware cell content (query-report cell channel, D-04 numbers). */
115
+ function ReportCellValue({
116
+ column,
117
+ row,
118
+ onOpenLink,
119
+ }: {
120
+ column: ReportColumnDef;
121
+ row: ReportRow;
122
+ onOpenLink?: (doctype: string, name: string) => void;
123
+ }) {
124
+ const raw = row[column.fieldname];
125
+ if (raw === null || raw === undefined || raw === "") {
126
+ return (
127
+ <Text fontSize="$3" color="$color8">
128
+ —
129
+ </Text>
130
+ );
131
+ }
132
+ if (column.fieldtype === "Check") {
133
+ return (
134
+ <Text fontSize="$3" color={raw ? "$green11" : "$color8"}>
135
+ {raw ? "✓" : "—"}
136
+ </Text>
137
+ );
138
+ }
139
+ if (REPORT_NUMERIC_FIELDTYPES.has(column.fieldtype)) {
140
+ const numeric = Number(raw);
141
+ return (
142
+ <Text fontSize="$3" color="$color12" fontFamily="$mono">
143
+ {Number.isFinite(numeric) ? formatNumericCell(numeric, column.fieldtype) : String(raw)}
144
+ </Text>
145
+ );
146
+ }
147
+ const value = String(raw);
148
+ switch (column.fieldtype) {
149
+ case "Date":
150
+ case "Datetime":
151
+ return (
152
+ <Text fontSize="$3" color="$color11" numberOfLines={1}>
153
+ {formatDateCell(value)}
154
+ </Text>
155
+ );
156
+ case "Link": {
157
+ const target = column.options;
158
+ if (target && onOpenLink) {
159
+ return (
160
+ <Text
161
+ fontSize="$3"
162
+ color="$color11"
163
+ textDecorationLine="underline"
164
+ cursor="pointer"
165
+ numberOfLines={1}
166
+ role="link"
167
+ onPress={(event) => {
168
+ event?.stopPropagation?.();
169
+ onOpenLink(target, value);
170
+ }}
171
+ >
172
+ {value}
173
+ </Text>
174
+ );
175
+ }
176
+ return (
177
+ <Text fontSize="$3" color="$color11" numberOfLines={1}>
178
+ {value}
179
+ </Text>
180
+ );
181
+ }
182
+ default:
183
+ return (
184
+ <Text fontSize="$3" color="$color11" numberOfLines={1}>
185
+ {value}
186
+ </Text>
187
+ );
188
+ }
189
+ }
190
+
191
+ export function ReportGrid({
192
+ columns,
193
+ rows,
194
+ sort,
195
+ onSortChange,
196
+ columnFilters,
197
+ onColumnFilterChange,
198
+ onOpenRow,
199
+ onOpenLink,
200
+ totals,
201
+ groupByColumn,
202
+ }: ReportGridProps) {
203
+ const { t } = useTranslation();
204
+
205
+ const handleSortPress = useCallback(
206
+ (fieldname: string) => {
207
+ if (sort.field === fieldname) {
208
+ onSortChange({ field: fieldname, order: sort.order === "asc" ? "desc" : "asc" });
209
+ } else {
210
+ onSortChange({ field: fieldname, order: "asc" });
211
+ }
212
+ },
213
+ [sort, onSortChange],
214
+ );
215
+
216
+ // ── Row press + keyboard (LC-49 ONE-TAB-STOP, LC-50 KEY-ACTIVATION) ──
217
+ // Mirrors DataTable's getRowPressProps contract (DataTableRoot): the
218
+ // DATA-row set exposes exactly one tab stop (roving anchor — the focused
219
+ // row while focus is inside the set, else the first row); Enter AND
220
+ // Space each fire exactly one activation; arrows/Home/End rove focus;
221
+ // inner interactive content (Link cells) wins over the row press.
222
+ const isSSR = typeof window === "undefined";
223
+ const [rowAnchor, setRowAnchor] = useState(0);
224
+ const dataRowCount = rows.reduce((count, row) => (row.kind === "row" ? count + 1 : count), 0);
225
+ const effectiveRowAnchor = Math.min(rowAnchor, Math.max(dataRowCount - 1, 0));
226
+
227
+ const getRowPressProps = useCallback(
228
+ (row: ReportRow, pressIndex: number) => {
229
+ const handlePress = (
230
+ event:
231
+ | { defaultPrevented?: boolean; target?: unknown; nativeEvent?: { target?: unknown } }
232
+ | undefined,
233
+ ) => {
234
+ if (event?.defaultPrevented) return;
235
+ const targetEl = event?.target ?? event?.nativeEvent?.target;
236
+ if (isWeb && typeof Element !== "undefined" && targetEl instanceof Element) {
237
+ const boundary = targetEl.closest('[data-report-row="true"]');
238
+ if (isInteractiveInnerTarget(targetEl, boundary)) return;
239
+ }
240
+ onOpenRow?.(row.name);
241
+ };
242
+ return {
243
+ // Accessible row name from doc identity (DG-A11Y-06); positional
244
+ // fallback for the defensive empty-name case.
245
+ "aria-label":
246
+ row.name || t("report.rowLabel", { defaultValue: "Row {{row}}", row: pressIndex + 1 }),
247
+ onPress: handlePress,
248
+ cursor: "pointer" as const,
249
+ hoverStyle: { backgroundColor: "$color2" },
250
+ pressStyle: { backgroundColor: "$color4" },
251
+ ...(isWeb && !isSSR
252
+ ? {
253
+ tabIndex: pressIndex === effectiveRowAnchor ? 0 : -1,
254
+ outlineWidth: 0,
255
+ focusStyle: { outlineWidth: 0 },
256
+ focusVisibleStyle: ensureFocusVisibleRing({ outlineOffset: -2 }),
257
+ "data-report-row": "true",
258
+ onFocus: (event: React.FocusEvent) => {
259
+ // Only a focused ROW moves the anchor; focus on an inner
260
+ // control (own tab stop) leaves the roving state alone.
261
+ if (event.target === event.currentTarget) setRowAnchor(pressIndex);
262
+ },
263
+ onBlur: (event: React.FocusEvent) => {
264
+ const next = event.relatedTarget;
265
+ if (!(next instanceof Element) || !next.closest('[data-report-row="true"]')) {
266
+ setRowAnchor(0);
267
+ }
268
+ },
269
+ onKeyDown: (event: React.KeyboardEvent) => {
270
+ if (event.defaultPrevented) return;
271
+ // Keys on focused inner controls belong to those controls.
272
+ if (event.target !== event.currentTarget) return;
273
+ const key = event.key;
274
+ if (key === "Enter" || key === " " || key === "Spacebar") {
275
+ // preventDefault stops Space page-scroll; one physical
276
+ // key press = one activation (auto-repeat guarded).
277
+ event.preventDefault();
278
+ event.stopPropagation();
279
+ if (event.repeat) return;
280
+ onOpenRow?.(row.name);
281
+ return;
282
+ }
283
+ if (key === "ArrowDown" || key === "ArrowUp" || key === "Home" || key === "End") {
284
+ event.preventDefault();
285
+ event.stopPropagation();
286
+ const rowEl = event.currentTarget as HTMLElement;
287
+ const scope = rowEl.closest('[data-report-grid="true"]') ?? rowEl.ownerDocument;
288
+ const pressRows = Array.from(
289
+ scope.querySelectorAll('[data-report-row="true"]'),
290
+ ) as HTMLElement[];
291
+ const currentIdx = pressRows.indexOf(rowEl);
292
+ if (currentIdx < 0) return;
293
+ const nextIdx =
294
+ key === "Home"
295
+ ? 0
296
+ : key === "End"
297
+ ? pressRows.length - 1
298
+ : Math.min(
299
+ Math.max(currentIdx + (key === "ArrowDown" ? 1 : -1), 0),
300
+ pressRows.length - 1,
301
+ );
302
+ pressRows[nextIdx]?.focus();
303
+ }
304
+ },
305
+ }
306
+ : {}),
307
+ };
308
+ },
309
+ [onOpenRow, effectiveRowAnchor, isSSR, t],
310
+ );
311
+
312
+ const totalsByField = new Map(totals.map((total) => [total.fieldname, total]));
313
+ const gridWidth = ROW_NUMBER_WIDTH + columns.reduce((sum, column) => sum + column.width, 0);
314
+
315
+ return (
316
+ <ScrollView horizontal showsHorizontalScrollIndicator flexGrow={0} flexShrink={1}>
317
+ <Table
318
+ minWidth={gridWidth}
319
+ width={gridWidth}
320
+ displayMode="flex"
321
+ cellHeight="$3"
322
+ // Roving scope marker: arrow-key navigation queries pressable rows
323
+ // within this container (getRowPressProps).
324
+ {...({ "data-report-grid": "true" } as Record<string, unknown>)}
325
+ >
326
+ <Table.Head>
327
+ {/* Header row: label + sort affordance (aria-sort, Enter/Space). */}
328
+ <Table.Row>
329
+ <Table.HeaderCell
330
+ cellWidth={ROW_NUMBER_WIDTH}
331
+ aria-label={t("report.rowNumber", "Row number")}
332
+ >
333
+ <Text fontSize="$2" color="$color9">
334
+ #
335
+ </Text>
336
+ </Table.HeaderCell>
337
+ {columns.map((column) => {
338
+ const sorted = sort.field === column.fieldname ? sort.order : undefined;
339
+ return (
340
+ <Table.HeaderCell
341
+ key={column.fieldname}
342
+ cellWidth={column.width}
343
+ justifyContent={alignToJustify(column.align)}
344
+ aria-sort={
345
+ sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : "none"
346
+ }
347
+ >
348
+ <Tooltip
349
+ content={
350
+ sorted === "asc"
351
+ ? t("report.sortDesc", "Sorted ascending (press to sort descending)")
352
+ : sorted === "desc"
353
+ ? t("report.sortAsc", "Sorted descending (press to sort ascending)")
354
+ : t("report.sortColumn", "Sort by this column")
355
+ }
356
+ >
357
+ <XStack
358
+ gap="$1.5"
359
+ alignItems="center"
360
+ cursor="pointer"
361
+ role="button"
362
+ tabIndex={0}
363
+ outlineWidth={0}
364
+ focusStyle={{ outlineWidth: 0 }}
365
+ focusVisibleStyle={ensureFocusVisibleRing({ outlineOffset: -2 })}
366
+ aria-label={t("report.sortBy", {
367
+ defaultValue: "Sort by {{column}}",
368
+ column: column.label,
369
+ })}
370
+ onPress={() => handleSortPress(column.fieldname)}
371
+ onKeyDown={(event: { key?: string; preventDefault?: () => void }) => {
372
+ if (event.key === "Enter" || event.key === " ") {
373
+ event.preventDefault?.();
374
+ handleSortPress(column.fieldname);
375
+ }
376
+ }}
377
+ hoverStyle={{ opacity: 0.8 }}
378
+ >
379
+ <Text fontSize="$3" fontWeight="400" numberOfLines={1}>
380
+ {column.label}
381
+ </Text>
382
+ {/* Caret orientation mirrors DataTable's headers:
383
+ asc = caret-down, desc = rotated 180. */}
384
+ <View aria-hidden role="presentation">
385
+ {sorted ? (
386
+ <CaretDownIcon
387
+ size={12}
388
+ color="var(--color10)"
389
+ style={sorted === "desc" ? { transform: "rotate(180deg)" } : undefined}
390
+ />
391
+ ) : (
392
+ <CaretUpDownIcon size={12} color="var(--color8)" />
393
+ )}
394
+ </View>
395
+ </XStack>
396
+ </Tooltip>
397
+ </Table.HeaderCell>
398
+ );
399
+ })}
400
+ </Table.Row>
401
+ {/* Filter row — desk parity: one input per column, standard filter
402
+ pipeline. Number/date accept comparison prefixes (>, <, >=…). */}
403
+ <Table.Row>
404
+ <Table.HeaderCell cellWidth={ROW_NUMBER_WIDTH} paddingVertical="$1.5" />
405
+ {columns.map((column) => {
406
+ const placeholder =
407
+ column.kind === "number" || column.kind === "date"
408
+ ? t("report.filterComparablePlaceholder", "= > <…")
409
+ : column.kind === "check"
410
+ ? t("report.filterCheckPlaceholder", "yes / no")
411
+ : t("report.filterPlaceholder", "Filter…");
412
+ return (
413
+ <Table.HeaderCell
414
+ key={column.fieldname}
415
+ cellWidth={column.width}
416
+ paddingVertical="$1.5"
417
+ overflow="visible"
418
+ >
419
+ <View flex={1} minWidth={0} overflow="visible">
420
+ <Input
421
+ size="$2"
422
+ compact
423
+ value={columnFilters[column.fieldname] ?? ""}
424
+ onChangeText={(next) => onColumnFilterChange(column.fieldname, next)}
425
+ placeholder={placeholder}
426
+ // inputProps is the eject that reaches the real input
427
+ // element — a top-level aria-label stays on the wrapper.
428
+ inputProps={{
429
+ "aria-label": t("report.filterColumn", {
430
+ defaultValue: "Filter {{column}}",
431
+ column: column.label,
432
+ }),
433
+ }}
434
+ />
435
+ </View>
436
+ </Table.HeaderCell>
437
+ );
438
+ })}
439
+ </Table.Row>
440
+ </Table.Head>
441
+ <Table.Body>
442
+ {(() => {
443
+ // Position within the pressable DATA-row set (group headers are
444
+ // not pressable and hold no tab stop).
445
+ let pressCursor = -1;
446
+ return rows.map((gridRow) => {
447
+ if (gridRow.kind === "group") {
448
+ const label =
449
+ gridRow.value === null || gridRow.value === undefined || gridRow.value === ""
450
+ ? t("report.groupEmpty", "(blank)")
451
+ : String(gridRow.value);
452
+ return (
453
+ <Table.Row key={gridRow.key} backgroundColor="$color2">
454
+ <Table.Cell cellWidth={gridWidth} paddingVertical="$1.5" role="rowheader">
455
+ <Text fontSize="$2" fontWeight="400" color="$color11" numberOfLines={1}>
456
+ {groupByColumn
457
+ ? t("report.groupHeader", {
458
+ defaultValue: "{{field}}: {{value}} · {{count}}",
459
+ field: groupByColumn.label,
460
+ value: label,
461
+ count: gridRow.count,
462
+ })
463
+ : label}
464
+ </Text>
465
+ </Table.Cell>
466
+ </Table.Row>
467
+ );
468
+ }
469
+ pressCursor += 1;
470
+ return (
471
+ // Spread LAST so the roving tabIndex/handlers stay in
472
+ // charge of this row's interactivity (DataTable precedent).
473
+ <Table.Row
474
+ key={gridRow.key}
475
+ {...(onOpenRow
476
+ ? (getRowPressProps(gridRow.row, pressCursor) as Record<string, unknown>)
477
+ : { "aria-label": gridRow.row.name })}
478
+ >
479
+ <Table.Cell cellWidth={ROW_NUMBER_WIDTH} paddingVertical="$2">
480
+ <Text fontSize="$2" color="$color9" fontFamily="$mono">
481
+ {gridRow.index}
482
+ </Text>
483
+ </Table.Cell>
484
+ {columns.map((column) => (
485
+ <Table.Cell
486
+ key={column.fieldname}
487
+ cellWidth={column.width}
488
+ paddingVertical="$2"
489
+ justifyContent={alignToJustify(column.align)}
490
+ >
491
+ <ReportCellValue column={column} row={gridRow.row} onOpenLink={onOpenLink} />
492
+ </Table.Cell>
493
+ ))}
494
+ </Table.Row>
495
+ );
496
+ });
497
+ })()}
498
+ </Table.Body>
499
+ {totals.length > 0 && (
500
+ <Table.Foot borderTopWidth={1} borderTopColor="$color6">
501
+ <Table.Row backgroundColor="$color2">
502
+ <Table.Cell cellWidth={ROW_NUMBER_WIDTH} paddingVertical="$2">
503
+ <Text fontSize="$2" fontWeight="400" color="$color10">
504
+ {t("report.totals", "Totals")}
505
+ </Text>
506
+ </Table.Cell>
507
+ {columns.map((column) => {
508
+ const total = totalsByField.get(column.fieldname);
509
+ return (
510
+ <Table.Cell
511
+ key={column.fieldname}
512
+ cellWidth={column.width}
513
+ paddingVertical="$2"
514
+ justifyContent={alignToJustify(column.align)}
515
+ >
516
+ {total ? (
517
+ <Text fontSize="$3" fontWeight="400" color="$color12" fontFamily="$mono">
518
+ {formatNumericCell(total.value, total.fieldtype)}
519
+ </Text>
520
+ ) : null}
521
+ </Table.Cell>
522
+ );
523
+ })}
524
+ </Table.Row>
525
+ </Table.Foot>
526
+ )}
527
+ </Table>
528
+ </ScrollView>
529
+ );
530
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * ReportToolbar DOM prop hygiene (LC-46 class).
3
+ *
4
+ * The Save As popover previously passed `elevate` to raw tamagui
5
+ * Popover.Content, which has no such variant in tamagui 2.0 — the bare
6
+ * boolean fell through to the DOM element and fired React's
7
+ * non-boolean-attribute console.error on every popover open (and painted
8
+ * no shadow: the "styling" was dead). The surface now rides the house
9
+ * PopoverContent (elevatedSurface knob recipe). Render + open → zero
10
+ * unknown-prop / non-boolean-attribute console errors.
11
+ */
12
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
13
+ import { fireEvent, waitFor } from "@testing-library/react";
14
+ import React from "react";
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
+ import type { ReportColumnDef } from "./reportViewModel";
17
+ import { ReportViewToolbar as ReportToolbar } from "./ReportViewToolbar";
18
+
19
+ const DOM_PROP_LEAK =
20
+ /does not recognize the `.+` prop on a DOM element|non-boolean attribute|Invalid DOM property/i;
21
+
22
+ const columns: ReportColumnDef[] = [
23
+ { fieldname: "name", label: "ID", fieldtype: "Data", kind: "text", width: 160, align: "left" },
24
+ {
25
+ fieldname: "status",
26
+ label: "Status",
27
+ fieldtype: "Select",
28
+ kind: "select",
29
+ options: "Open\nClosed",
30
+ width: 120,
31
+ align: "left",
32
+ },
33
+ ];
34
+
35
+ function renderToolbar() {
36
+ return renderWithProviders(
37
+ <ReportToolbar
38
+ columns={columns}
39
+ selected={["name", "status"]}
40
+ onToggleColumn={vi.fn()}
41
+ onResetColumns={vi.fn()}
42
+ groupBy={null}
43
+ onGroupByChange={vi.fn()}
44
+ baseFilters={[]}
45
+ columnFilterCount={0}
46
+ onClearColumnFilters={vi.fn()}
47
+ savedReports={[]}
48
+ currentReport={null}
49
+ onSelectReport={vi.fn()}
50
+ onSaveReport={vi.fn()}
51
+ onDeleteReport={vi.fn()}
52
+ onExport={vi.fn()}
53
+ />,
54
+ );
55
+ }
56
+
57
+ let errorSpy: ReturnType<typeof vi.spyOn> | null = null;
58
+ beforeEach(() => {
59
+ // Strict local spy: the shared test setup filters known leak warnings
60
+ // globally, but this arm must stay falsifiable — capture everything.
61
+ errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
62
+ });
63
+ afterEach(() => {
64
+ errorSpy?.mockRestore();
65
+ errorSpy = null;
66
+ });
67
+
68
+ function leakMessages(): string[] {
69
+ return (errorSpy?.mock.calls ?? [])
70
+ .map((args: unknown[]) => args.map(String).join(" "))
71
+ .filter((message) => DOM_PROP_LEAK.test(message));
72
+ }
73
+
74
+ describe("ReportToolbar Save As popover (LC-46 DOM prop hygiene)", () => {
75
+ it("opens the Save As popover without leaking non-DOM props", async () => {
76
+ const result = renderToolbar();
77
+
78
+ const saveAs = result.findTextElement("Save As…");
79
+ expect(saveAs).toBeDefined();
80
+ fireEvent.click(saveAs as Element);
81
+
82
+ await waitFor(() => {
83
+ expect(document.querySelector('input[placeholder="New report name"]')).toBeTruthy();
84
+ });
85
+
86
+ expect(leakMessages()).toEqual([]);
87
+ // Belt-and-braces: no leaked attribute rendered anywhere either.
88
+ expect(document.querySelector("[elevate]")).toBeNull();
89
+ });
90
+ });