@svgrid/enterprise 2.7.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/README.md +95 -95
  2. package/dist/ExpressionEditorHarness.svelte.d.ts +2 -2
  3. package/dist/SvAdvancedFilter.svelte.d.ts +2 -2
  4. package/dist/SvAlertRuleEditor.svelte.d.ts +2 -2
  5. package/dist/SvAlertsManager.svelte.d.ts +3 -3
  6. package/dist/SvAlertsPanel.svelte.d.ts +1 -1
  7. package/dist/SvAuthGate.svelte +115 -115
  8. package/dist/SvAuthGate.svelte.d.ts +1 -1
  9. package/dist/SvBoard.svelte.d.ts +1 -1
  10. package/dist/SvExportMenu.svelte +553 -553
  11. package/dist/SvExportMenu.svelte.d.ts +2 -2
  12. package/dist/SvExpressionEditor.svelte.d.ts +2 -2
  13. package/dist/SvFileInput.svelte +113 -113
  14. package/dist/SvGridAlerts.svelte.d.ts +2 -2
  15. package/dist/SvGridBoard.svelte +2358 -2358
  16. package/dist/SvGridBulkEditDrawer.svelte.d.ts +1 -1
  17. package/dist/SvGridEditPanel.svelte +1157 -1157
  18. package/dist/SvGridEditPanel.svelte.d.ts +3 -3
  19. package/dist/SvGridMasterDetail.svelte +116 -116
  20. package/dist/SvGridMasterDetail.svelte.d.ts +1 -1
  21. package/dist/SvGridScheduler.svelte +5358 -5358
  22. package/dist/SvImportDialog.svelte +1020 -1020
  23. package/dist/SvImportDialog.svelte.d.ts +2 -2
  24. package/dist/SvLookupInput.svelte +180 -180
  25. package/dist/SvLookupInput.svelte.d.ts +1 -1
  26. package/dist/SvPivotDesigner.svelte +1990 -1990
  27. package/dist/SvPivotDesigner.svelte.d.ts +2 -2
  28. package/dist/SvRecordDetail.svelte.d.ts +1 -1
  29. package/dist/SvSchedule.svelte.d.ts +1 -1
  30. package/dist/SvSchemaChart.svelte +234 -234
  31. package/dist/SvSchemaChart.svelte.d.ts +2 -2
  32. package/dist/SvSchemaDashboard.svelte +130 -130
  33. package/dist/SvSchemaDashboard.svelte.d.ts +3 -3
  34. package/dist/advanced-filter/expr-columns-from-grid.d.ts +1 -1
  35. package/dist/advanced-filter-enable.js +3 -3
  36. package/dist/alerts/alert-engine-attach.d.ts +2 -2
  37. package/dist/alerts/alert-engine-attach.js +3 -3
  38. package/dist/alerts/alert-engine.d.ts +1 -1
  39. package/dist/alerts/alert-engine.js +2 -2
  40. package/dist/alerts/alert-formats.d.ts +1 -1
  41. package/dist/alerts/alert-formats.js +1 -1
  42. package/dist/alerts/alert-observer.d.ts +3 -3
  43. package/dist/alerts/alert-observer.js +1 -1
  44. package/dist/alerts/alert-storage.d.ts +1 -1
  45. package/dist/alerts/alert-store.svelte.d.ts +1 -1
  46. package/dist/alerts/alert-types.d.ts +2 -2
  47. package/dist/alerts.js +2 -2
  48. package/dist/board.js +2 -2
  49. package/dist/cdn/svgrid-enterprise.svelte-external.js +1162 -1145
  50. package/dist/designer/assets/GridMenus-B1JtIN21.js +8 -0
  51. package/dist/designer/assets/SvDateRangeInput-C0iCCZ8o.css +1 -0
  52. package/dist/designer/assets/{SvDateRangeInput-CVn-yqrQ.js → SvDateRangeInput-CjO7nn_x.js} +1 -1
  53. package/dist/designer/assets/SvDateTimePicker-ByBD_ov6.js +1 -0
  54. package/dist/designer/assets/SvDateTimePicker-DPv09DIT.css +1 -0
  55. package/dist/designer/assets/SvField-YDLipR6O.css +1 -0
  56. package/dist/designer/assets/{SvField-BsqiA5me.js → SvField-cj1PFNgZ.js} +1 -1
  57. package/dist/designer/assets/{preload-helper-CG0n22O7.js → SvGrid.helpers-BgT-gnCo.js} +1 -1
  58. package/dist/designer/assets/SvGridCellEditor-0W1L2hJ1.js +2 -0
  59. package/dist/designer/assets/SvGridChart-DBOR4xcz.js +2 -0
  60. package/dist/designer/assets/SvGridChart-fkMWeh1J.css +1 -0
  61. package/dist/designer/assets/SvGridChartPanel-symrhNnR.js +11 -0
  62. package/dist/designer/assets/SvGridChartView-BmRxq6mU.js +1 -0
  63. package/dist/designer/assets/SvGridChartView-DUc-1RUf.css +1 -0
  64. package/dist/designer/assets/SvGridDropdown-Bb3HH2Aj.js +1 -0
  65. package/dist/designer/assets/SvGridDropdown-CcidB3rm.css +1 -0
  66. package/dist/designer/assets/SvListBox-1oUF6Iav.js +110 -0
  67. package/dist/designer/assets/SvListBox-BXfyqy4a.css +1 -0
  68. package/dist/designer/assets/cell-formatting-wr5wm7US.js +1 -0
  69. package/dist/designer/assets/chart-DXFganU8.js +1 -0
  70. package/dist/designer/assets/date-format-DGagAqRc.css +1 -0
  71. package/dist/designer/assets/{date-format-4DJvNtG2.js → date-format-onlYDsrY.js} +1 -1
  72. package/dist/designer/assets/export-format-D-eP4b2l.js +6 -0
  73. package/dist/designer/assets/{index-BvaLmQSd.js → index-C_a9Cj8Y.js} +2984 -141
  74. package/dist/designer/assets/index-k2stW6L8.css +1 -0
  75. package/dist/designer/assets/preload-helper-HclGiUj8.js +1 -0
  76. package/dist/designer/assets/{row-drag-touch-IuJPEt0z.js → row-drag-touch-BdknxBNL.js} +1 -1
  77. package/dist/designer/assets/src-D9p27OGC.js +58 -0
  78. package/dist/designer/index.html +22 -22
  79. package/dist/export-conditional.d.ts +1 -1
  80. package/dist/export-print.js +19 -19
  81. package/dist/export-xls.d.ts +2 -2
  82. package/dist/export.d.ts +2 -2
  83. package/dist/export.js +7 -7
  84. package/dist/expressions/builder-tree.d.ts +2 -2
  85. package/dist/expressions/builder-tree.js +1 -1
  86. package/dist/expressions/compile.d.ts +1 -1
  87. package/dist/import.js +1 -1
  88. package/dist/index.d.ts +53 -53
  89. package/dist/index.js +50 -50
  90. package/dist/install.d.ts +4 -4
  91. package/dist/install.js +11 -11
  92. package/dist/license.d.ts +3 -2
  93. package/dist/license.js +9 -4
  94. package/dist/node/studio.js +124 -115
  95. package/dist/pivot-chart.d.ts +1 -1
  96. package/dist/pivot-designer.d.ts +1 -1
  97. package/dist/pivot-enable.js +3 -3
  98. package/dist/print.d.ts +1 -1
  99. package/dist/print.js +4 -4
  100. package/dist/scheduler-config.d.ts +5 -5
  101. package/dist/scheduler-freebusy.d.ts +1 -1
  102. package/dist/scheduler-freebusy.js +1 -1
  103. package/dist/scheduler.js +2 -2
  104. package/dist/schema-designer.d.ts +1 -1
  105. package/dist/schema-designer.js +2 -2
  106. package/dist/selection-bar.js +2 -2
  107. package/dist/sources/aggregate.d.ts +1 -1
  108. package/dist/sources/auth-supabase.js +1 -1
  109. package/dist/sources/dashboard.d.ts +2 -2
  110. package/dist/sources/dashboard.js +1 -1
  111. package/dist/sources/index.d.ts +13 -13
  112. package/dist/sources/index.js +13 -13
  113. package/dist/sources/realtime-supabase.js +1 -1
  114. package/dist/sources/relation-lookup.d.ts +1 -1
  115. package/dist/sources/relation-lookup.js +1 -1
  116. package/dist/sources/rest-adapters.d.ts +1 -1
  117. package/dist/sources/rest-adapters.js +1 -1
  118. package/dist/sources/rest.d.ts +2 -2
  119. package/dist/sources/rest.js +3 -3
  120. package/dist/sources/supabase.d.ts +2 -2
  121. package/dist/sources/supabase.js +3 -3
  122. package/dist/sources/with-entity-rules.d.ts +1 -1
  123. package/dist/sources/with-entity-rules.js +1 -1
  124. package/dist/sources/with-relation-labels.d.ts +1 -1
  125. package/dist/studio/emit-project.js +2161 -2127
  126. package/dist/studio/emit-schema.js +369 -369
  127. package/dist/studio/index.d.ts +1 -1
  128. package/dist/studio/index.js +1 -1
  129. package/dist/studio/introspect-db.js +45 -45
  130. package/dist/studio/project.d.ts +10 -0
  131. package/dist/studio/samples/crm.js +6 -6
  132. package/dist/studio/samples/fleet.js +7 -7
  133. package/dist/studio/samples/insurance.js +9 -9
  134. package/dist/studio/samples/inventory.js +12 -12
  135. package/dist/studio/samples/projects.js +5 -5
  136. package/dist/studio/samples/support.js +8 -8
  137. package/dist/studio/scaffold-app.js +104 -104
  138. package/dist/studio/scaffold.js +151 -151
  139. package/dist/studio/ui-components.d.ts +7 -0
  140. package/dist/studio/ui-components.js +35 -0
  141. package/dist/sveltekit/in-memory.d.ts +3 -3
  142. package/dist/sveltekit/in-memory.js +4 -4
  143. package/dist/sveltekit/index.d.ts +6 -6
  144. package/dist/sveltekit/index.js +5 -5
  145. package/dist/sveltekit/query-plan.d.ts +1 -1
  146. package/dist/sveltekit/sql-source.d.ts +4 -4
  147. package/dist/sveltekit/sql-source.js +4 -4
  148. package/dist/sveltekit/sql.d.ts +1 -1
  149. package/dist/sveltekit/transport.d.ts +2 -2
  150. package/dist/sveltekit/transport.js +1 -1
  151. package/dist/upgrade-prompt.js +25 -25
  152. package/dist/version.d.ts +1 -1
  153. package/dist/version.js +1 -1
  154. package/package.json +6 -3
  155. package/dist/designer/assets/GridMenus-Cgilexpk.js +0 -8
  156. package/dist/designer/assets/SvDateRangeInput-BV5OXYQ2.css +0 -1
  157. package/dist/designer/assets/SvDateTimePicker-BdioTrJQ.js +0 -1
  158. package/dist/designer/assets/SvDateTimePicker-CY8TK4BT.css +0 -1
  159. package/dist/designer/assets/SvField-BF-9TJSd.css +0 -1
  160. package/dist/designer/assets/SvGridCellEditor-dLY7_kk9.js +0 -2
  161. package/dist/designer/assets/SvGridChart-BEDNTdc5.js +0 -2
  162. package/dist/designer/assets/SvGridChart-Vu4Nvjiv.css +0 -1
  163. package/dist/designer/assets/SvGridChartPanel-x3lzSxuf.js +0 -10
  164. package/dist/designer/assets/SvGridChartView-CAKhPELi.js +0 -1
  165. package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +0 -1
  166. package/dist/designer/assets/SvGridDropdown-CAYG7Y-O.js +0 -1
  167. package/dist/designer/assets/SvGridDropdown-Crwb2P3Q.css +0 -1
  168. package/dist/designer/assets/SvListBox-CVYQcLX9.js +0 -1
  169. package/dist/designer/assets/SvListBox-DgN8kvjt.css +0 -1
  170. package/dist/designer/assets/chart-DHEgt4lN.js +0 -1
  171. package/dist/designer/assets/date-format-Dzy4mTGh.css +0 -1
  172. package/dist/designer/assets/export-format-DwjuTfrQ.js +0 -6
  173. package/dist/designer/assets/index-Cp7fDJ3b.css +0 -1
  174. package/dist/designer/assets/js-scroller.svelte-Bhxqb3-c.js +0 -110
  175. package/dist/designer/assets/src-7qOYsBug.js +0 -2896
  176. package/dist/designer/assets/src-B7DNcoQK.css +0 -1
  177. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/_svelte_metadata.json +0 -1
  178. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/_metadata.json +0 -178
  179. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-BOv1rBKp.js +0 -3875
  180. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/attachments-BOv1rBKp.js.map +0 -1
  181. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-B9vgIdCd.js +0 -21
  182. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/constants-B9vgIdCd.js.map +0 -1
  183. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-T1qbW_qD.js +0 -1938
  184. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/dev-T1qbW_qD.js.map +0 -1
  185. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-Bo11SfVK.js +0 -205
  186. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/events-Bo11SfVK.js.map +0 -1
  187. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-X7D4vfrZ.js +0 -18
  188. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/flags-X7D4vfrZ.js.map +0 -1
  189. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-oyUje5i1.js +0 -264
  190. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/hydration-oyUje5i1.js.map +0 -1
  191. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-DeSQgSFm.js +0 -697
  192. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/legacy-client-DeSQgSFm.js.map +0 -1
  193. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/package.json +0 -3
  194. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js +0 -134
  195. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte.js.map +0 -1
  196. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js +0 -55
  197. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_animate.js.map +0 -1
  198. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_attachments.js +0 -2
  199. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js +0 -46843
  200. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_compiler.js.map +0 -1
  201. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js +0 -237
  202. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_easing.js.map +0 -1
  203. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_events.js +0 -2
  204. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js +0 -4
  205. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal.js.map +0 -1
  206. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_client.js +0 -7
  207. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js +0 -4
  208. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_disclose-version.js.map +0 -1
  209. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js +0 -5
  210. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_async.js.map +0 -1
  211. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js +0 -5
  212. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_legacy.js.map +0 -1
  213. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js +0 -5
  214. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_flags_tracing.js.map +0 -1
  215. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_internal_server.js +0 -6
  216. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js +0 -83
  217. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_legacy.js.map +0 -1
  218. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js +0 -559
  219. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_motion.js.map +0 -1
  220. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js +0 -24
  221. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity.js.map +0 -1
  222. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js +0 -122
  223. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_reactivity_window.js.map +0 -1
  224. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_server.js +0 -2
  225. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js +0 -89
  226. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_store.js.map +0 -1
  227. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js +0 -241
  228. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/svelte_transition.js.map +0 -1
  229. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-BbWyXNk1.js +0 -4938
  230. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-BbWyXNk1.js.map +0 -1
  231. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DI_fC7vt.js +0 -495
  232. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/utils-DI_fC7vt.js.map +0 -1
  233. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-Dyr3lQv9.js +0 -190
  234. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/validate-Dyr3lQv9.js.map +0 -1
  235. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-_9FHH-LF.js +0 -8
  236. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/version-_9FHH-LF.js.map +0 -1
  237. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-CUbSPnyP.js +0 -138
  238. package/dist/studio/node_modules/.vite/vitest/be65d27ae088a0e03fd8e1331d90b01649464cb6/deps___vitest__/warnings-CUbSPnyP.js.map +0 -1
@@ -1,1990 +1,1990 @@
1
- <script lang="ts" module>
2
- type PivotInputRow = Record<string, unknown>
3
- </script>
4
- <script lang="ts" generics="T extends PivotInputRow">
5
- /**
6
- * SvPivotDesigner - Excel-style pivot table designer.
7
- *
8
- * Self-contained, controlled component. The consumer holds the data
9
- * + the field list + the layout state; everything else (drag-and-drop,
10
- * chip menus, search, presets, the inline pivot grid) is built in.
11
- *
12
- * Minimal wire-up (in a .svelte file):
13
- * const fields = [
14
- * { field: 'region', label: 'Region', kind: 'dimension' },
15
- * { field: 'quarter', label: 'Quarter', kind: 'dimension' },
16
- * { field: 'amount', label: 'Revenue', kind: 'measure', defaultAgg: 'sum' },
17
- * ]
18
- * let layout = $state(defaultLayoutFor(fields))
19
- *
20
- * SvPivotDesigner {data} {fields} bind:layout
21
- */
22
- import {
23
- SvGrid,
24
- SvGridChart,
25
- SvMenuList,
26
- tableFeatures,
27
- rowSortingFeature,
28
- columnFilteringFeature,
29
- renderSnippet,
30
- portalToBody,
31
- popIn,
32
- createDismissableLayer,
33
- createFocusTrap,
34
- onScrollOutside,
35
- type ColumnDef,
36
- type ChartType,
37
- type MenuItem,
38
- } from '@svgrid/grid'
39
- import { createPivotModel, filterCollapsedPivotRows, type PivotRow, type PivotAggregatorId } from './pivot'
40
- import { pivotToChartSpec } from './pivot-chart'
41
- import {
42
- ALL_AGGREGATORS, AGG_LABEL, EMPTY_LAYOUT, defaultLayoutFor,
43
- type PivotField, type PivotLayout, type PivotPreset, type Well,
44
- } from './pivot-designer'
45
-
46
- type Props = {
47
- /** Flat input rows. */
48
- data: T[]
49
- /** All fields the user can pick from the rail. */
50
- fields: PivotField<T>[]
51
- /** The current pivot layout. Bindable so the consumer can persist it. */
52
- layout?: PivotLayout
53
- /** Fired when the user changes the layout (drag, drop, chip menu, …). */
54
- onLayoutChange?: (layout: PivotLayout) => void
55
-
56
- // ---- Optional features --------------------------------------------
57
- /** Saved layouts surfaced in the toolbar's Presets menu. */
58
- presets?: PivotPreset[]
59
- /** Aggregators offered in the Values chip menu. Default: all. */
60
- aggregators?: PivotAggregatorId[]
61
- /** Show the toolbar above the wells. Default true. */
62
- showToolbar?: boolean
63
- /** Show the left-rail field picker. Default true. */
64
- showFieldList?: boolean
65
- /** Show the Filters well. Default true. */
66
- showFiltersWell?: boolean
67
- /** Custom Export handler. When set, an Export button appears. */
68
- onExport?: (layout: PivotLayout, rows: PivotRow[]) => void
69
- /** Height of the inner pivot grid. Default '100%'. */
70
- gridHeight?: string | number
71
- /** Render the embedded grid? Set false to host it separately and
72
- * read `pivot` from the on:pivot event. Default true. */
73
- embedGrid?: boolean
74
- /** Fires whenever the underlying pivot model rebuilds. */
75
- onPivot?: (rows: PivotRow[], columns: ColumnDef<typeof features, PivotRow>[]) => void
76
- /** Allow row-level expand / collapse. When true, an expand chevron
77
- * appears in the label cell on every `group` row and clicking it
78
- * toggles which descendant rows are visible. The designer manages
79
- * the collapsed set internally. Default false. */
80
- expandable?: boolean
81
- /** Transform the generated column tree right before the grid renders.
82
- * The consumer can attach custom `cell:` / `header:` / `cellClass:`
83
- * properties, change widths, etc. Receives the full tree + the
84
- * current layout (so it can branch on which measures are present). */
85
- decorateColumns?: (
86
- cols: ColumnDef<typeof features, PivotRow>[],
87
- layout: PivotLayout,
88
- ) => ColumnDef<typeof features, PivotRow>[]
89
- /** Click handler forwarded to the embedded SvGrid - typically used
90
- * to drive a drill-through side panel. */
91
- onCellClick?: (ctx: { columnId: string; row: PivotRow; value: unknown }) => void
92
- /** Offer a Table <-> Chart view toggle (same layout as a live chart). Default true. */
93
- chartable?: boolean
94
- /** Which view to show first when `chartable`. Default 'table'. */
95
- defaultView?: 'table' | 'chart'
96
-
97
- // ---- Presentation -------------------------------------------------
98
- /**
99
- * Where the authoring UI (field picker + wells) lives relative to the
100
- * grid. `'top'` (default) keeps the classic Excel layout: left rail +
101
- * a horizontal row of wells above the grid. `'right'` docks everything
102
- * as a single vertical tool panel on the right of the grid - the
103
- * conventional "enterprise data grid" pivot-panel arrangement.
104
- */
105
- panelPosition?: 'top' | 'right'
106
- /** Width of the docked panel when `panelPosition='right'`. Default 280. */
107
- panelWidth?: number | string
108
- /**
109
- * Enables a "Pivot Mode" toggle. When OFF the embedded grid renders the
110
- * flat source rows with `flatColumns`; when ON it renders the pivot.
111
- * Requires `flatColumns` to be provided. Bindable.
112
- */
113
- pivotMode?: boolean
114
- /** Columns for the flat (pivot-off) grid. Enables the Pivot Mode toggle. */
115
- flatColumns?: ColumnDef<typeof features, T>[]
116
- /** Fired when the Pivot Mode toggle changes. */
117
- onPivotModeChange?: (on: boolean) => void
118
- /** Fit the embedded grid's columns to width. Default true. Set false to
119
- * let a wide pivot scroll horizontally (recommended with many columns). */
120
- gridFitColumns?: boolean
121
- /** Enable the right-click context menu on field-list rows, well chips, and
122
- * the pivot grid (add/move/remove fields, change aggregator, expand /
123
- * collapse). Default true. */
124
- contextMenu?: boolean
125
- /**
126
- * Column virtualization on the embedded grid. Default true. Set FALSE for
127
- * wide pivots with grouped column headers: the grouped header band isn't
128
- * virtualized, so a virtualized body leaves off-window group columns
129
- * looking empty until scrolled. Disabling it renders every column, so
130
- * header and body always stay in sync (fine up to a few hundred columns).
131
- */
132
- columnVirtualization?: boolean
133
- /**
134
- * Render the field picker as a **Columns** tool panel: a
135
- * collapsible column-group TREE whose checkboxes toggle column VISIBILITY
136
- * (with a select-all), instead of the flat "tick to add to a well" list.
137
- * Fields still drag into the Rows / Columns / Values wells. The embedded
138
- * flat grid (pivot off) hides deselected columns. Default false.
139
- */
140
- columnTree?: boolean
141
- /** Hidden column ids (field names) when `columnTree` is on. Bindable. */
142
- hiddenFields?: string[]
143
- /** Fired when column visibility changes (columnTree mode). */
144
- onHiddenFieldsChange?: (hidden: string[]) => void
145
- /**
146
- * Show a vertical Columns / Filters tab rail on the docked panel
147
- * (`panelPosition='right'`). The Filters tab is a set-filter
148
- * builder: "Add Filter" -> pick a column -> tick which values pass. Filters
149
- * narrow the source rows (they drive `layout.filters`). Default false.
150
- */
151
- toolTabs?: boolean
152
- }
153
- let {
154
- data,
155
- fields,
156
- layout = $bindable<PivotLayout>(defaultLayoutFor([])),
157
- onLayoutChange,
158
- presets,
159
- aggregators = ALL_AGGREGATORS,
160
- showToolbar = true,
161
- showFieldList = true,
162
- showFiltersWell = true,
163
- onExport,
164
- gridHeight = '100%',
165
- embedGrid = true,
166
- onPivot,
167
- expandable = false,
168
- decorateColumns,
169
- onCellClick,
170
- chartable = true,
171
- defaultView = 'table',
172
- panelPosition = 'top',
173
- panelWidth = 280,
174
- pivotMode = $bindable(true),
175
- flatColumns,
176
- onPivotModeChange,
177
- gridFitColumns = true,
178
- columnVirtualization = true,
179
- contextMenu = true,
180
- columnTree = false,
181
- hiddenFields = $bindable<string[]>([]),
182
- onHiddenFieldsChange,
183
- toolTabs = false,
184
- }: Props = $props()
185
-
186
- // ---- Columns / Filters tab rail ----------------------------------
187
- let activeTab = $state<'columns' | 'filters'>('columns')
188
- let filterCollapsed = $state<Set<string>>(new Set())
189
- let filterSearch = $state<Record<string, string>>({})
190
- let addFilterOpen = $state(false)
191
- let addFilterSearch = $state('')
192
- function toggleFilterCard(field: string) {
193
- const next = new Set(filterCollapsed)
194
- if (next.has(field)) next.delete(field); else next.add(field)
195
- filterCollapsed = next
196
- }
197
- function toggleFilterValue(f: { field: string; allowed: string[] | null }, v: string, all: string[]) {
198
- const cur = f.allowed ?? all.slice()
199
- const next = cur.includes(v) ? cur.filter((x) => x !== v) : [...cur, v]
200
- setFilterAllowed(f.field, next.length === all.length ? null : next)
201
- }
202
- function addFilterField(field: string) {
203
- if (!layout.filters.some((f) => f.field === field)) {
204
- emit({ ...layout, filters: [...layout.filters, { field, allowed: null }] })
205
- }
206
- const nc = new Set(filterCollapsed); nc.delete(field); filterCollapsed = nc
207
- addFilterOpen = false
208
- addFilterSearch = ''
209
- }
210
- const availableFilterFields = $derived.by(() => {
211
- const used = new Set(layout.filters.map((f) => f.field))
212
- const q = addFilterSearch.trim().toLowerCase()
213
- return fields.filter((f) => !used.has(f.field) && (!q || f.label.toLowerCase().includes(q)))
214
- })
215
- // Close the Add-Filter column picker on outside click.
216
- $effect(() => {
217
- if (!addFilterOpen) return
218
- function on(e: MouseEvent) {
219
- const t = e.target as HTMLElement | null
220
- if (t && t.closest(`[data-pvd-addfilter="${uid}"]`)) return
221
- addFilterOpen = false
222
- }
223
- window.addEventListener('mousedown', on)
224
- return () => window.removeEventListener('mousedown', on)
225
- })
226
-
227
- const showPivotToggle = $derived(!!flatColumns)
228
- function setPivotMode(on: boolean) {
229
- pivotMode = on
230
- onPivotModeChange?.(on)
231
- }
232
-
233
- // Default the layout once on mount if the consumer passed nothing
234
- // meaningful. We seed inside $effect.pre so $bindable picks up the
235
- // mutation BEFORE the first render reads it.
236
- $effect.pre(() => {
237
- if (!layout || (!layout.rows.length && !layout.cols.length && !layout.values.length && !layout.filters.length)) {
238
- layout = defaultLayoutFor(fields)
239
- }
240
- })
241
-
242
- const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
243
- const uid = `pvd-${Math.random().toString(36).slice(2, 8)}`
244
-
245
- // Lookup helpers -----------------------------------------------------
246
- const fieldsByName = $derived(new Map(fields.map((f) => [f.field, f])))
247
- /** Field is currently "in use" - on any well - so the picker can grey it out. */
248
- function isFieldInLayout(field: string): boolean {
249
- return layout.rows.includes(field) ||
250
- layout.cols.includes(field) ||
251
- layout.values.some((v) => v.field === field) ||
252
- layout.filters.some((f) => f.field === field)
253
- }
254
-
255
- // ---- Search + grouped picker --------------------------------------
256
- let search = $state('')
257
- const filteredFields = $derived(
258
- fields.filter((f) => !search.trim() || f.label.toLowerCase().includes(search.trim().toLowerCase())),
259
- )
260
- const groupedFields = $derived.by(() => {
261
- const groups = new Map<string, PivotField<T>[]>()
262
- for (const f of filteredFields) {
263
- const key = f.group ?? (f.kind === 'dimension' ? 'Dimensions' : 'Measures')
264
- const arr = groups.get(key) ?? []
265
- arr.push(f); groups.set(key, arr)
266
- }
267
- return [...groups.entries()]
268
- })
269
-
270
- // ---- Columns tool panel: visibility tree (columnTree mode) --------
271
- const hiddenSet = $derived(new Set(hiddenFields))
272
- const isFieldVisible = (field: string) => !hiddenSet.has(field)
273
- let collapsedGroups = $state<Set<string>>(new Set())
274
- function toggleGroupCollapsed(group: string) {
275
- const next = new Set(collapsedGroups)
276
- if (next.has(group)) next.delete(group); else next.add(group)
277
- collapsedGroups = next
278
- }
279
- function emitHidden(next: Set<string>) {
280
- const arr = [...next]
281
- hiddenFields = arr
282
- onHiddenFieldsChange?.(arr)
283
- }
284
- function setFieldVisible(field: string, visible: boolean) {
285
- const next = new Set(hiddenSet)
286
- if (visible) next.delete(field); else next.add(field)
287
- emitHidden(next)
288
- }
289
- /** Every field id (across all groups), regardless of the search filter. */
290
- const allFieldIds = $derived(fields.map((f) => f.field))
291
- /** Visibility of a whole group: 'all' | 'some' | 'none'. */
292
- function groupVisibility(items: PivotField<T>[]): 'all' | 'some' | 'none' {
293
- const vis = items.filter((f) => isFieldVisible(f.field)).length
294
- return vis === 0 ? 'none' : vis === items.length ? 'all' : 'some'
295
- }
296
- function toggleGroupVisible(items: PivotField<T>[]) {
297
- const makeVisible = groupVisibility(items) !== 'all'
298
- const next = new Set(hiddenSet)
299
- for (const f of items) { if (makeVisible) next.delete(f.field); else next.add(f.field) }
300
- emitHidden(next)
301
- }
302
- const allVisibility = $derived.by<'all' | 'some' | 'none'>(() => {
303
- const vis = allFieldIds.filter((id) => isFieldVisible(id)).length
304
- return vis === 0 ? 'none' : vis === allFieldIds.length ? 'all' : 'some'
305
- })
306
- function toggleAllVisible() {
307
- const makeVisible = allVisibility !== 'all'
308
- emitHidden(makeVisible ? new Set<string>() : new Set(allFieldIds))
309
- }
310
- /** `flatColumns` with hidden leaves removed (and now-empty groups dropped). */
311
- const visibleFlatColumns = $derived.by(() => {
312
- if (!flatColumns || !columnTree || hiddenSet.size === 0) return flatColumns
313
- type Col = ColumnDef<typeof features, T>
314
- const filter = (cols: Col[]): Col[] => {
315
- const out: Col[] = []
316
- for (const c of cols) {
317
- const kids = (c as { columns?: Col[] }).columns
318
- if (kids?.length) {
319
- const fk = filter(kids)
320
- if (fk.length) out.push({ ...c, columns: fk } as Col)
321
- } else {
322
- const id = ((c as { field?: string }).field ?? c.id) as string | undefined
323
- if (!id || !hiddenSet.has(id)) out.push(c)
324
- }
325
- }
326
- return out
327
- }
328
- return filter(flatColumns)
329
- })
330
-
331
- // ---- Mutation helpers ---------------------------------------------
332
- function emit(next: PivotLayout) {
333
- layout = next
334
- onLayoutChange?.(next)
335
- }
336
- function defaultWellFor(field: PivotField<T>): Well {
337
- return field.kind === 'measure' ? 'values' : 'rows'
338
- }
339
- /** Add a field to a well at `index` (or end). For rows / cols / filters a
340
- * field can appear at most ONCE - dragging it in moves it. For values
341
- * the same field with the SAME aggregator can only appear once, but
342
- * the same field with DIFFERENT aggregators is valid (e.g. spend/sum
343
- * + spend/avg in a scorecard). `sourceIndex` is the chip's position
344
- * in `values` when dragging within that well, used to dedup the move. */
345
- function addToWell(field: string, well: Well, index = Infinity, sourceIndex?: number) {
346
- const f = fieldsByName.get(field)
347
- if (!f) return
348
- const next: PivotLayout = {
349
- ...layout,
350
- rows: layout.rows.filter((x) => x !== field),
351
- cols: layout.cols.filter((x) => x !== field),
352
- // For values: only strip if we're MOVING a specific chip out of the
353
- // well. Don't strip every chip with the same field (that broke
354
- // "spend/sum + spend/avg" presets with a duplicate-key error).
355
- values: sourceIndex !== undefined
356
- ? layout.values.filter((_, i) => i !== sourceIndex)
357
- : layout.values.slice(),
358
- filters: layout.filters.filter((v) => v.field !== field),
359
- }
360
- if (well === 'rows' || well === 'cols') {
361
- const arr = next[well]
362
- arr.splice(Math.min(arr.length, index), 0, field)
363
- } else if (well === 'values') {
364
- const agg = f.defaultAgg ?? 'sum' as PivotAggregatorId
365
- // Skip if (field, agg) pair already present after the strip.
366
- if (next.values.some((v) => v.field === field && v.agg === agg)) {
367
- emit(next); return
368
- }
369
- const chip = { field, agg, label: f.label, format: f.format }
370
- next.values.splice(Math.min(next.values.length, index), 0, chip)
371
- } else if (well === 'filters') {
372
- next.filters.splice(Math.min(next.filters.length, index), 0, { field, allowed: null })
373
- }
374
- emit(next)
375
- // A filter added with `allowed: null` passes every row, so dropping a field
376
- // into Filters changes nothing until you pick values - auto-open the picker
377
- // so the drop has an immediate, obvious effect.
378
- if (well === 'filters') openMenu = { kind: 'filter', field }
379
- }
380
- function removeFromWell(field: string, well: Well, valueIndex?: number) {
381
- const next: PivotLayout = { ...layout }
382
- if (well === 'rows') next.rows = layout.rows.filter((x) => x !== field)
383
- else if (well === 'cols') next.cols = layout.cols.filter((x) => x !== field)
384
- else if (well === 'values') {
385
- // Remove a SPECIFIC chip by its index, since the same field can
386
- // appear multiple times with different aggregators.
387
- next.values = valueIndex !== undefined
388
- ? layout.values.filter((_, i) => i !== valueIndex)
389
- : layout.values.filter((v) => v.field !== field)
390
- }
391
- else if (well === 'filters') next.filters = layout.filters.filter((v) => v.field !== field)
392
- emit(next)
393
- }
394
- function toggleFieldDefault(field: string) {
395
- const f = fieldsByName.get(field); if (!f) return
396
- if (isFieldInLayout(field)) {
397
- // Remove from every well (all chips for this field, even multiple
398
- // value chips with different aggregators).
399
- emit({
400
- ...layout,
401
- rows: layout.rows.filter((x) => x !== field),
402
- cols: layout.cols.filter((x) => x !== field),
403
- values: layout.values.filter((v) => v.field !== field),
404
- filters: layout.filters.filter((v) => v.field !== field),
405
- })
406
- } else {
407
- addToWell(field, defaultWellFor(f))
408
- }
409
- }
410
- /** Update the aggregator of the chip at the given index. If the change
411
- * would produce a duplicate (field, agg) pair, the chip is removed
412
- * instead of creating a key collision. */
413
- function setAggregatorAt(index: number, agg: PivotAggregatorId) {
414
- const chip = layout.values[index]
415
- if (!chip) return
416
- const field = chip.field
417
- const dup = layout.values.some((v, i) => i !== index && v.field === field && v.agg === agg)
418
- const label = AGG_LABEL[agg] + ' of ' + (fieldsByName.get(field)?.label ?? field)
419
- emit({
420
- ...layout,
421
- values: dup
422
- ? layout.values.filter((_, i) => i !== index)
423
- : layout.values.map((v, i) => (i === index ? { ...v, agg, label } : v)),
424
- })
425
- }
426
- function setFilterAllowed(field: string, allowed: string[] | null) {
427
- emit({
428
- ...layout,
429
- filters: layout.filters.map((f) => (f.field === field ? { ...f, allowed } : f)),
430
- })
431
- }
432
- function toggleHideSubtotals() {
433
- emit({ ...layout, hideSubtotals: !layout.hideSubtotals })
434
- }
435
- function toggleHideGrandTotals() {
436
- emit({ ...layout, hideGrandTotals: !layout.hideGrandTotals })
437
- }
438
- function reset() {
439
- emit(defaultLayoutFor(fields))
440
- }
441
- function loadPreset(p: PivotPreset) {
442
- emit(structuredClone(p.layout) as PivotLayout)
443
- }
444
-
445
- // ---- Drag-and-drop ------------------------------------------------
446
- /** What's being dragged: field id + the well it came from. `dragIndex`
447
- * is the source position in the values well (only set when dragging a
448
- * value chip; other wells have at most one chip per field so they
449
- * don't need an index). */
450
- let dragField = $state<string | null>(null)
451
- let dragFrom = $state<Well | 'rail' | null>(null)
452
- let dragIndex = $state<number | undefined>(undefined)
453
- let dragOver = $state<Well | null>(null)
454
- function onDragStart(e: DragEvent, field: string, from: Well | 'rail', index?: number) {
455
- dragField = field
456
- dragFrom = from
457
- dragIndex = index
458
- if (e.dataTransfer) {
459
- e.dataTransfer.effectAllowed = 'move'
460
- e.dataTransfer.setData('text/plain', field)
461
- }
462
- }
463
- function onDragOver(e: DragEvent, well: Well) {
464
- e.preventDefault()
465
- if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
466
- dragOver = well
467
- }
468
- function onDragLeave() { dragOver = null }
469
- function onDrop(e: DragEvent, well: Well) {
470
- e.preventDefault()
471
- dragOver = null
472
- const field = dragField || e.dataTransfer?.getData('text/plain') || ''
473
- if (!field) return
474
- // Pass the source index only when dragging FROM the values well, so
475
- // a same-field move (e.g. reorder spend/avg) removes the right chip.
476
- const srcIdx = dragFrom === 'values' ? dragIndex : undefined
477
- addToWell(field, well, Infinity, srcIdx)
478
- dragField = null
479
- dragFrom = null
480
- dragIndex = undefined
481
- }
482
-
483
- // ---- Apply filters BEFORE pivot ------------------------------------
484
- // The pivot model itself has no filtering, but the well clearly should:
485
- // a Filters chip with `allowed` restricts which rows reach the model.
486
- const filteredData = $derived.by(() => {
487
- if (!layout.filters.length) return data
488
- return data.filter((row) => {
489
- for (const f of layout.filters) {
490
- if (f.allowed == null) continue
491
- const v = row[f.field]
492
- if (!f.allowed.includes(String(v))) return false
493
- }
494
- return true
495
- })
496
- })
497
-
498
- // ---- Build the pivot model ----------------------------------------
499
- // Whenever the layout or data changes, re-run the pivot. Empty layouts
500
- // are tolerated: the grid will show a friendly empty state.
501
- const pivot = $derived.by(() => {
502
- if (!layout.values.length && !layout.rows.length && !layout.cols.length) return null
503
- if (!layout.values.length) return null
504
- return createPivotModel(filteredData, {
505
- rows: layout.rows as Array<keyof T & string>,
506
- cols: layout.cols as Array<keyof T & string>,
507
- values: layout.values.map((v) => ({
508
- field: v.field as keyof T & string,
509
- agg: v.agg,
510
- label: v.label,
511
- format: v.format,
512
- })),
513
- grandTotalRow: !layout.hideGrandTotals,
514
- grandTotalCol: !layout.hideGrandTotals,
515
- rowSubtotals: !layout.hideSubtotals,
516
- })
517
- })
518
- $effect(() => {
519
- if (pivot) onPivot?.(pivot.rows, pivot.columns)
520
- })
521
-
522
- // ---- Table <-> Chart view (the same layout as a live pivot chart) ----
523
- let view = $state<'table' | 'chart'>(defaultView === 'chart' && chartable ? 'chart' : 'table')
524
- let chartType = $state<ChartType>('bar')
525
- let chartStacked = $state(false)
526
- const CHART_TYPES: Array<{ value: ChartType; label: string }> = [
527
- { value: 'bar', label: 'Bar' },
528
- { value: 'line', label: 'Line' },
529
- { value: 'area', label: 'Area' },
530
- { value: 'pie', label: 'Pie' },
531
- ]
532
- const chartSpec = $derived.by(() =>
533
- pivot && chartable && view === 'chart'
534
- ? pivotToChartSpec(pivot, { type: chartType, stacked: chartStacked })
535
- : null,
536
- )
537
-
538
- // ---- Expand / collapse (when `expandable` is on) ------------------
539
- // We hold a `collapsed` set of pivot row ids; descendants of a
540
- // collapsed group are filtered out via the model helper. Reset to
541
- // fully-expanded whenever the source pivot rebuilds with different
542
- // group ids - otherwise stale ids would linger across layout changes.
543
- let collapsed = $state<Set<string>>(new Set())
544
- let lastPivotKey = $state('')
545
- $effect(() => {
546
- if (!pivot) return
547
- const key = pivot.rows.map((r) => r.__pivotId).join('|')
548
- if (key !== lastPivotKey) {
549
- lastPivotKey = key
550
- // Keep ids still present, drop the rest.
551
- const present = new Set(pivot.rows.map((r) => r.__pivotId))
552
- const next = new Set<string>()
553
- for (const id of collapsed) if (present.has(id)) next.add(id)
554
- if (next.size !== collapsed.size) collapsed = next
555
- }
556
- })
557
- function toggleRow(id: string) {
558
- const next = new Set(collapsed)
559
- if (next.has(id)) next.delete(id); else next.add(id)
560
- collapsed = next
561
- }
562
- function expandAll() { collapsed = new Set() }
563
- function collapseAll() {
564
- if (!pivot) return
565
- const next = new Set<string>()
566
- for (const r of pivot.rows) if (r.__pivotExpandable) next.add(r.__pivotId)
567
- collapsed = next
568
- }
569
- /** Rows the grid should actually render, honouring the collapsed set. */
570
- const visibleRows = $derived.by(() => {
571
- if (!pivot) return [] as PivotRow[]
572
- if (!expandable || collapsed.size === 0) return pivot.rows
573
- const expanded = new Set<string>()
574
- for (const r of pivot.rows) {
575
- if (r.__pivotExpandable && !collapsed.has(r.__pivotId)) expanded.add(r.__pivotId)
576
- }
577
- return filterCollapsedPivotRows(pivot.rows, expanded)
578
- })
579
-
580
- // ---- Column tree with optional decoration -------------------------
581
- // When `expandable`, the first (label) column gets a built-in chevron
582
- // renderer. The consumer's `decorateColumns` runs LAST so it can
583
- // override even the chevron behaviour if it wants something custom.
584
- const finalColumns = $derived.by(() => {
585
- if (!pivot) return [] as ColumnDef<typeof features, PivotRow>[]
586
- let cols = pivot.columns
587
- // The engine builds the row-header column header from raw field names
588
- // (e.g. "region / country") which renders lowercase. Replace it with
589
- // the chained `field.label` from the picker so the rendered header
590
- // matches the chip text in the wells (e.g. "Region / Country").
591
- if (cols.length && layout.rows.length) {
592
- const rowLabel = layout.rows
593
- .map((f) => fieldsByName.get(f)?.label ?? f)
594
- .join(' / ')
595
- cols = [{ ...cols[0]!, header: rowLabel }, ...cols.slice(1)]
596
- }
597
- if (expandable) {
598
- cols = cols.map((c, i) => {
599
- if (i !== 0) return c
600
- return {
601
- ...c,
602
- cell: (ctx) => renderSnippet(ChevronLabelCell, {
603
- row: ctx.row.original,
604
- collapsed: collapsed.has(ctx.row.original.__pivotId),
605
- onToggle: () => toggleRow(ctx.row.original.__pivotId),
606
- }),
607
- }
608
- })
609
- }
610
- return decorateColumns ? decorateColumns(cols, layout) : cols
611
- })
612
-
613
- // ---- Filter chip menu (which values pass) --------------------------
614
- /** Distinct values for a field, computed from the FULL data (so the
615
- * menu shows every option, not just those the current filters
616
- * already pass). */
617
- function distinctValuesFor(field: string): string[] {
618
- const set = new Set<string>()
619
- for (const row of data) set.add(String(row[field]))
620
- return [...set].sort((a, b) => a.localeCompare(b))
621
- }
622
-
623
- // ---- Active menus (open one chip at a time) ----------------------
624
- // Filter menus key by `field` (one filter chip per field).
625
- // Agg menus key by `index` (a field can have multiple value chips at
626
- // different aggregators; index is the only stable identity).
627
- let openMenu = $state<
628
- | { kind: 'filter'; field: string }
629
- | { kind: 'agg'; index: number }
630
- | null
631
- >(null)
632
- function toggleFilterMenu(field: string) {
633
- if (openMenu?.kind === 'filter' && openMenu.field === field) openMenu = null
634
- else openMenu = { kind: 'filter', field }
635
- }
636
- function toggleAggMenu(index: number) {
637
- if (openMenu?.kind === 'agg' && openMenu.index === index) openMenu = null
638
- else openMenu = { kind: 'agg', index }
639
- }
640
- function closeMenu() { openMenu = null }
641
- // Close on outside click.
642
- $effect(() => {
643
- if (!openMenu) return
644
- function on(e: MouseEvent) {
645
- const t = e.target as HTMLElement | null
646
- if (t && t.closest(`[data-pvd-menu="${uid}"]`)) return
647
- openMenu = null
648
- }
649
- window.addEventListener('mousedown', on)
650
- return () => window.removeEventListener('mousedown', on)
651
- })
652
-
653
- // ---- Presets menu ------------------------------------------------
654
- let presetsOpen = $state(false)
655
- function togglePresets() { presetsOpen = !presetsOpen }
656
- $effect(() => {
657
- if (!presetsOpen) return
658
- function on(e: MouseEvent) {
659
- const t = e.target as HTMLElement | null
660
- if (t && t.closest(`[data-pvd-presets="${uid}"]`)) return
661
- presetsOpen = false
662
- }
663
- window.addEventListener('mousedown', on)
664
- return () => window.removeEventListener('mousedown', on)
665
- })
666
-
667
- // ---- Right-click context menu ------------------------------------
668
- // A single portalled SvMenuList whose items are computed from whatever
669
- // was right-clicked: a field-list row, a well chip, or the pivot grid.
670
- const WELL_LABEL: Record<Well, string> = { rows: 'Rows', cols: 'Columns', values: 'Values', filters: 'Filters' }
671
- const KIND_WELLS: Record<'dimension' | 'measure', Well[]> = {
672
- dimension: ['rows', 'cols', 'filters'],
673
- measure: ['values', 'filters'],
674
- }
675
- let ctx = $state<{ x: number; y: number; items: MenuItem[] } | null>(null)
676
- let ctxPanelEl = $state<HTMLDivElement | null>(null)
677
- function openCtx(e: MouseEvent, items: MenuItem[]) {
678
- if (!contextMenu || items.length === 0) return
679
- e.preventDefault()
680
- e.stopPropagation()
681
- const W = 210
682
- const estH = Math.min(items.length, 12) * 32 + 10
683
- const x = Math.min(e.clientX, window.innerWidth - W - 6)
684
- const y = Math.min(e.clientY, window.innerHeight - estH - 6)
685
- ctx = { x: Math.max(6, x), y: Math.max(6, y), items }
686
- }
687
- function closeCtx() { ctx = null }
688
- $effect(() => {
689
- if (!ctx || !ctxPanelEl) return
690
- const trap = createFocusTrap(ctxPanelEl, {
691
- initialFocus: () => ctxPanelEl?.querySelector<HTMLElement>('[role="menuitem"]:not([disabled])') ?? null,
692
- })
693
- trap.activate()
694
- const layer = createDismissableLayer({ element: () => ctxPanelEl, onDismiss: closeCtx })
695
- layer.activate()
696
- const offScroll = onScrollOutside(() => ctxPanelEl, closeCtx)
697
- return () => { layer.release(); trap.release(); offScroll() }
698
- })
699
-
700
- function clearWell(well: Well) {
701
- if (well === 'rows') emit({ ...layout, rows: [] })
702
- else if (well === 'cols') emit({ ...layout, cols: [] })
703
- else if (well === 'values') emit({ ...layout, values: [] })
704
- else emit({ ...layout, filters: [] })
705
- }
706
-
707
- /** Menu for a field-list (rail) row. */
708
- function fieldMenu(field: string): MenuItem[] {
709
- const f = fieldsByName.get(field)
710
- if (!f) return []
711
- const items: MenuItem[] = KIND_WELLS[f.kind].map((w) => ({
712
- label: `Add to ${WELL_LABEL[w]}`,
713
- icon: icPlus,
714
- onSelect: () => addToWell(field, w),
715
- }))
716
- if (isFieldInLayout(field)) {
717
- items.push({ separator: true }, { label: 'Remove from pivot', icon: icTrash, onSelect: () => toggleFieldDefault(field) })
718
- }
719
- return items
720
- }
721
-
722
- /** Menu for a chip already sitting in a well. */
723
- function chipMenu(well: Well, field: string, valueIndex?: number): MenuItem[] {
724
- const f = fieldsByName.get(field)
725
- const items: MenuItem[] = []
726
- if (well === 'values' && valueIndex !== undefined) {
727
- const current = layout.values[valueIndex]?.agg
728
- items.push({
729
- label: 'Aggregate',
730
- icon: icSigma,
731
- children: aggregators.map((agg) => ({
732
- label: AGG_LABEL[agg],
733
- shortcut: agg === current ? '✓' : undefined,
734
- onSelect: () => setAggregatorAt(valueIndex, agg),
735
- })),
736
- })
737
- }
738
- if (f) {
739
- const targets = KIND_WELLS[f.kind].filter((w) => w !== well)
740
- if (targets.length) {
741
- items.push({
742
- label: 'Move to',
743
- icon: icMove,
744
- children: targets.map((w) => ({
745
- label: WELL_LABEL[w],
746
- onSelect: () => addToWell(field, w, Infinity, well === 'values' ? valueIndex : undefined),
747
- })),
748
- })
749
- }
750
- }
751
- if (well === 'filters') items.push({ label: 'Edit filter…', icon: icFilter, onSelect: () => toggleFilterMenu(field) })
752
- items.push(
753
- { separator: true },
754
- { label: 'Remove', icon: icTrash, onSelect: () => removeFromWell(field, well, valueIndex) },
755
- { label: `Clear ${WELL_LABEL[well]}`, icon: icClear, onSelect: () => clearWell(well) },
756
- )
757
- return items
758
- }
759
-
760
- /** Menu for an empty area of a well. */
761
- function wellMenu(well: Well): MenuItem[] {
762
- const count = well === 'values' ? layout.values.length : layout[well].length
763
- return [{ label: `Clear ${WELL_LABEL[well]}`, icon: icClear, disabled: count === 0, onSelect: () => clearWell(well) }]
764
- }
765
-
766
- /** Menu for the pivot grid area (expand / collapse when expandable). */
767
- function gridMenu(): MenuItem[] {
768
- const items: MenuItem[] = []
769
- if (expandable && pivot) {
770
- items.push(
771
- { label: 'Expand all', icon: icExpand, onSelect: expandAll },
772
- { label: 'Collapse all', icon: icCollapse, onSelect: collapseAll },
773
- )
774
- }
775
- if (onExport && pivot) {
776
- if (items.length) items.push({ separator: true })
777
- items.push({ label: 'Export…', icon: icExport, onSelect: () => onExport!(layout, pivot!.rows) })
778
- }
779
- return items
780
- }
781
- </script>
782
-
783
- <section class="pvd" data-uid={uid}>
784
- {#if showToolbar}
785
- <header class="pvd-toolbar">
786
- <strong class="pvd-title">Pivot designer</strong>
787
- <button type="button" class="pvd-btn" onclick={reset} title="Restore the default layout">{@render ic('reset')} Reset</button>
788
- {#if presets?.length}
789
- <div class="pvd-presets" data-pvd-presets={uid}>
790
- <button type="button" class="pvd-btn" onclick={togglePresets}>{@render ic('presets')} Presets {@render ic('chevron-down')}</button>
791
- {#if presetsOpen}
792
- <div class="pvd-popover">
793
- {#each presets as p (p.name)}
794
- <button type="button" class="pvd-popover-item" onclick={() => { loadPreset(p); presetsOpen = false }}>{p.name}</button>
795
- {/each}
796
- </div>
797
- {/if}
798
- </div>
799
- {/if}
800
- <label class="pvd-toggle">
801
- <input type="checkbox" checked={!layout.hideSubtotals} onchange={toggleHideSubtotals} /> Subtotals
802
- </label>
803
- <label class="pvd-toggle">
804
- <input type="checkbox" checked={!layout.hideGrandTotals} onchange={toggleHideGrandTotals} /> Grand totals
805
- </label>
806
- {#if chartable}
807
- <div class="pvd-viewswitch" role="group" aria-label="View">
808
- <button type="button" class="pvd-view-btn" class:is-active={view === 'table'} onclick={() => (view = 'table')}>{@render ic('table')} Table</button>
809
- <button type="button" class="pvd-view-btn" class:is-active={view === 'chart'} onclick={() => (view = 'chart')}>{@render ic('chart')} Chart</button>
810
- </div>
811
- {#if view === 'chart'}
812
- <label class="pvd-toggle">
813
- <select class="pvd-select" value={chartType} onchange={(e) => (chartType = e.currentTarget.value as ChartType)}>
814
- {#each CHART_TYPES as t (t.value)}<option value={t.value}>{t.label}</option>{/each}
815
- </select>
816
- </label>
817
- {#if chartType !== 'pie'}
818
- <label class="pvd-toggle">
819
- <input type="checkbox" checked={chartStacked} onchange={(e) => (chartStacked = e.currentTarget.checked)} /> Stacked
820
- </label>
821
- {/if}
822
- {/if}
823
- {/if}
824
- <div class="pvd-spacer"></div>
825
- {#if onExport && pivot}
826
- <button type="button" class="pvd-btn pvd-btn-primary" onclick={() => onExport!(layout, pivot!.rows)}>{@render ic('export')} Export…</button>
827
- {/if}
828
- </header>
829
- {/if}
830
-
831
- <div class="pvd-body" data-panel={panelPosition} class:no-rail={!showFieldList && panelPosition === 'top'}>
832
- {#if panelPosition === 'right'}
833
- <div class="pvd-gridwrap">{@render gridBlock()}</div>
834
- <aside class="pvd-panel" class:pvd-panel-tabbed={toolTabs} style={`width:${typeof panelWidth === 'number' ? panelWidth + 'px' : panelWidth}`} aria-label="Pivot panel">
835
- {#if toolTabs}
836
- <div class="pvd-tabbody">
837
- {#if activeTab === 'columns'}{@render columnsPanel()}{:else}{@render filtersPanel()}{/if}
838
- </div>
839
- <div class="pvd-tabrail" role="tablist" aria-label="Tool panel tabs">
840
- <button type="button" class="pvd-tabbtn" role="tab" aria-selected={activeTab === 'columns'} class:is-active={activeTab === 'columns'} onclick={() => (activeTab = 'columns')}>
841
- {@render ic('columns')}<span>Columns</span>
842
- </button>
843
- <button type="button" class="pvd-tabbtn" role="tab" aria-selected={activeTab === 'filters'} class:is-active={activeTab === 'filters'} onclick={() => (activeTab = 'filters')}>
844
- {@render ic('filter')}<span>Filters</span>
845
- </button>
846
- </div>
847
- {:else}
848
- {@render columnsPanel()}
849
- {/if}
850
- </aside>
851
- {:else}
852
- {#if showFieldList}
853
- <aside class="pvd-rail" aria-label="Available fields">{@render railBlock()}</aside>
854
- {/if}
855
- <div class="pvd-main">
856
- {#if showPivotToggle}<div class="pvd-topbar">{@render pivotToggle()}</div>{/if}
857
- <div class="pvd-wells" class:two={!showFiltersWell}>{@render wellsBlock()}</div>
858
- {@render gridBlock()}
859
- </div>
860
- {/if}
861
- </div>
862
- </section>
863
-
864
- {#if ctx}
865
- <div
866
- bind:this={ctxPanelEl}
867
- class="pvd-ctx"
868
- use:portalToBody
869
- use:popIn={{}}
870
- style:position="fixed"
871
- style:top={`${ctx.y}px`}
872
- style:left={`${ctx.x}px`}
873
- role="presentation"
874
- >
875
- <SvMenuList items={ctx.items} onclose={closeCtx} onselect={() => {}} />
876
- </div>
877
- {/if}
878
-
879
- <!-- ============================ Icons ============================ -->
880
- {#snippet ic(name: string)}
881
- <svg class="pvd-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
882
- {#if name === 'filter'}
883
- <path d="M3 5h18l-7 8v6l-4 2v-8z" />
884
- {:else if name === 'columns'}
885
- <rect x="3" y="4" width="18" height="16" rx="1.5" />
886
- <path d="M9 4v16" /><path d="M15 4v16" />
887
- {:else if name === 'rows'}
888
- <rect x="3" y="4" width="18" height="16" rx="1.5" />
889
- <path d="M3 10h18" /><path d="M3 15h18" />
890
- {:else if name === 'sigma'}
891
- <path d="M17 5H7l6 7-6 7h10" />
892
- {:else if name === 'dimension'}
893
- <path d="M7 7h.01" />
894
- <path d="M11 3h4.6a2 2 0 0 1 1.4.6l4 4a2 2 0 0 1 0 2.8l-6.6 6.6a2 2 0 0 1-2.8 0l-4-4a2 2 0 0 1-.6-1.4V6a3 3 0 0 1 3-3z" />
895
- {:else if name === 'reset'}
896
- <path d="M3 12a9 9 0 1 0 3-6.7L3 8" /><path d="M3 3v5h5" />
897
- {:else if name === 'presets'}
898
- <path d="M12 3l8 4.5-8 4.5-8-4.5z" /><path d="M4 12l8 4.5 8-4.5" /><path d="M4 16.5l8 4.5 8-4.5" />
899
- {:else if name === 'export'}
900
- <path d="M12 3v12" /><path d="M8 11l4 4 4-4" /><path d="M4 19h16" />
901
- {:else if name === 'table'}
902
- <rect x="3" y="4" width="18" height="16" rx="1.5" />
903
- <path d="M3 9h18" /><path d="M9 9v11" />
904
- {:else if name === 'chart'}
905
- <path d="M4 20V4" /><path d="M4 20h16" />
906
- <rect x="7" y="12" width="3" height="5" /><rect x="12" y="8" width="3" height="9" /><rect x="17" y="5" width="3" height="12" />
907
- {:else if name === 'chevron-down'}
908
- <path d="M6 9l6 6 6-6" />
909
- {:else if name === 'grip'}
910
- <circle cx="9" cy="6" r="1.3" fill="currentColor" stroke="none" /><circle cx="15" cy="6" r="1.3" fill="currentColor" stroke="none" />
911
- <circle cx="9" cy="12" r="1.3" fill="currentColor" stroke="none" /><circle cx="15" cy="12" r="1.3" fill="currentColor" stroke="none" />
912
- <circle cx="9" cy="18" r="1.3" fill="currentColor" stroke="none" /><circle cx="15" cy="18" r="1.3" fill="currentColor" stroke="none" />
913
- {:else if name === 'plus'}
914
- <path d="M12 5v14" /><path d="M5 12h14" />
915
- {:else if name === 'move'}
916
- <path d="M5 9l-3 3 3 3" /><path d="M9 5l3-3 3 3" /><path d="M15 19l-3 3-3-3" /><path d="M19 9l3 3-3 3" /><path d="M2 12h20" /><path d="M12 2v20" />
917
- {:else if name === 'trash'}
918
- <path d="M4 7h16" /><path d="M9 7V5h6v2" /><path d="M6 7l1 13h10l1-13" />
919
- {:else if name === 'clear'}
920
- <path d="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6" /><path d="M14 11v6" />
921
- {:else if name === 'expand'}
922
- <path d="M8 3H5a2 2 0 0 0-2 2v3" /><path d="M16 3h3a2 2 0 0 1 2 2v3" /><path d="M8 21H5a2 2 0 0 1-2-2v-3" /><path d="M16 21h3a2 2 0 0 0 2-2v-3" />
923
- {:else if name === 'collapse'}
924
- <path d="M3 8h3a2 2 0 0 0 2-2V3" /><path d="M21 8h-3a2 2 0 0 1-2-2V3" /><path d="M3 16h3a2 2 0 0 1 2 2v3" /><path d="M21 16h-3a2 2 0 0 0-2 2v3" />
925
- {:else if name === 'pivot'}
926
- <rect x="3" y="3" width="18" height="18" rx="1.5" /><path d="M3 9h18" /><path d="M9 9v12" />
927
- {:else if name === 'check'}
928
- <path d="M5 13l4 4 10-11" />
929
- {:else if name === 'addfilter'}
930
- <path d="M3 6h13" /><path d="M3 12h9" /><path d="M3 18h6" />
931
- <path d="M17 13v8" /><path d="M13 17h8" />
932
- {:else if name === 'search'}
933
- <circle cx="11" cy="11" r="6" /><path d="M20 20l-4.5-4.5" />
934
- {/if}
935
- </svg>
936
- {/snippet}
937
-
938
- <!-- Zero-arg wrappers so icons can be attached to context-menu items. -->
939
- {#snippet icPlus()}{@render ic('plus')}{/snippet}
940
- {#snippet icTrash()}{@render ic('trash')}{/snippet}
941
- {#snippet icClear()}{@render ic('clear')}{/snippet}
942
- {#snippet icMove()}{@render ic('move')}{/snippet}
943
- {#snippet icSigma()}{@render ic('sigma')}{/snippet}
944
- {#snippet icFilter()}{@render ic('filter')}{/snippet}
945
- {#snippet icExpand()}{@render ic('expand')}{/snippet}
946
- {#snippet icCollapse()}{@render ic('collapse')}{/snippet}
947
- {#snippet icExport()}{@render ic('export')}{/snippet}
948
-
949
- <!-- ============================ Sub-blocks ============================ -->
950
- {#snippet pivotToggle()}
951
- <label class="pvd-pivot-toggle">
952
- <input type="checkbox" checked={pivotMode} onchange={(e) => setPivotMode(e.currentTarget.checked)} />
953
- <span class="pvd-switch" class:on={pivotMode}><span class="pvd-switch-knob"></span></span>
954
- <span class="pvd-pivot-icon">{@render ic('pivot')}</span>
955
- <span class="pvd-pivot-label">Pivot Mode</span>
956
- </label>
957
- {/snippet}
958
-
959
- {#snippet railBlock()}
960
- {#if columnTree}
961
- <!-- AG-style Columns tool panel: select-all + search, then a collapsible
962
- column-group TREE whose checkboxes toggle column visibility. Fields
963
- still drag into the wells. -->
964
- <div class="pvd-tree-top">
965
- <input
966
- type="checkbox"
967
- class="pvd-tree-check"
968
- checked={allVisibility === 'all'}
969
- indeterminate={allVisibility === 'some'}
970
- onchange={toggleAllVisible}
971
- aria-label="Toggle all columns"
972
- />
973
- <input type="search" class="pvd-search pvd-tree-search" placeholder="Search…" bind:value={search} />
974
- </div>
975
- <div class="pvd-fieldlist pvd-tree">
976
- {#each groupedFields as [groupName, items] (groupName)}
977
- {@const gv = groupVisibility(items)}
978
- {@const open = !collapsedGroups.has(groupName)}
979
- <div class="pvd-tree-group">
980
- <button type="button" class="pvd-tree-twisty" class:is-open={open} onclick={() => toggleGroupCollapsed(groupName)} aria-label={open ? `Collapse ${groupName}` : `Expand ${groupName}`}>
981
- {@render ic('chevron-down')}
982
- </button>
983
- <input type="checkbox" class="pvd-tree-check" checked={gv === 'all'} indeterminate={gv === 'some'} onchange={() => toggleGroupVisible(items)} aria-label={`Toggle ${groupName}`} />
984
- <span class="pvd-grip" aria-hidden="true">{@render ic('grip')}</span>
985
- <span class="pvd-tree-group-name">{groupName}</span>
986
- </div>
987
- {#if open}
988
- {#each items as f (f.field)}
989
- <div
990
- class="pvd-tree-leaf"
991
- draggable="true"
992
- ondragstart={(e) => onDragStart(e, f.field, 'rail')}
993
- oncontextmenu={(e) => openCtx(e, fieldMenu(f.field))}
994
- role="button"
995
- tabindex="0"
996
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setFieldVisible(f.field, !isFieldVisible(f.field)) } }}
997
- >
998
- <input type="checkbox" class="pvd-tree-check" checked={isFieldVisible(f.field)} onchange={(e) => setFieldVisible(f.field, e.currentTarget.checked)} aria-label={`Toggle ${f.label}`} />
999
- <span class="pvd-grip" aria-hidden="true">{@render ic('grip')}</span>
1000
- <span class="pvd-field-kind" class:is-measure={f.kind === 'measure'} title={f.kind === 'dimension' ? 'Dimension' : 'Measure'}>
1001
- {@render ic(f.kind === 'dimension' ? 'dimension' : 'sigma')}
1002
- </span>
1003
- <span class="pvd-field-label">{f.label}</span>
1004
- </div>
1005
- {/each}
1006
- {/if}
1007
- {/each}
1008
- {#if !filteredFields.length}
1009
- <div class="pvd-empty">No columns match "{search}"</div>
1010
- {/if}
1011
- </div>
1012
- {:else}
1013
- <input
1014
- type="search"
1015
- class="pvd-search"
1016
- placeholder="Search fields…"
1017
- bind:value={search}
1018
- />
1019
- <div class="pvd-fieldlist">
1020
- {#each groupedFields as [groupName, items] (groupName)}
1021
- <div class="pvd-group-head">{groupName}</div>
1022
- {#each items as f (f.field)}
1023
- {@const inUse = isFieldInLayout(f.field)}
1024
- <div
1025
- class="pvd-field"
1026
- class:in-use={inUse}
1027
- draggable="true"
1028
- ondragstart={(e) => onDragStart(e, f.field, 'rail')}
1029
- oncontextmenu={(e) => openCtx(e, fieldMenu(f.field))}
1030
- role="button"
1031
- tabindex="0"
1032
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleFieldDefault(f.field) } }}
1033
- >
1034
- <input type="checkbox" checked={inUse} onchange={() => toggleFieldDefault(f.field)} aria-label={`Toggle ${f.label}`} />
1035
- <span class="pvd-field-kind" class:is-measure={f.kind === 'measure'} title={f.kind === 'dimension' ? 'Dimension' : 'Measure'}>
1036
- {@render ic(f.kind === 'dimension' ? 'dimension' : 'sigma')}
1037
- </span>
1038
- <span class="pvd-field-label">{f.label}</span>
1039
- </div>
1040
- {/each}
1041
- {/each}
1042
- {#if !filteredFields.length}
1043
- <div class="pvd-empty">No fields match "{search}"</div>
1044
- {/if}
1045
- </div>
1046
- {/if}
1047
- {/snippet}
1048
-
1049
- {#snippet wellsBlock()}
1050
- <!-- Filters -->
1051
- {#if showFiltersWell}
1052
- <div class="pvd-well"
1053
- class:drag-over={dragOver === 'filters'}
1054
- ondragover={(e) => onDragOver(e, 'filters')}
1055
- ondragleave={onDragLeave}
1056
- ondrop={(e) => onDrop(e, 'filters')}
1057
- oncontextmenu={(e) => openCtx(e, wellMenu('filters'))}>
1058
- <div class="pvd-well-head">{@render ic('filter')} Filters</div>
1059
- <div class="pvd-well-body">
1060
- {#each layout.filters as f (f.field)}
1061
- {@const fd = fieldsByName.get(f.field)}
1062
- <div class="pvd-chip" draggable="true" ondragstart={(e) => onDragStart(e, f.field, 'filters')} oncontextmenu={(e) => openCtx(e, chipMenu('filters', f.field))}>
1063
- <span class="pvd-chip-grip" aria-hidden="true">{@render ic('grip')}</span>
1064
- <button type="button" class="pvd-chip-label" title="Choose which values pass" onclick={() => toggleFilterMenu(f.field)}>
1065
- {fd?.label ?? f.field}{f.allowed ? `: ${f.allowed.length}` : ''} <span class="pvd-chip-caret">▾</span>
1066
- </button>
1067
- <button type="button" class="pvd-chip-x" onclick={() => removeFromWell(f.field, 'filters')} aria-label="Remove">×</button>
1068
- {#if openMenu?.kind === 'filter' && openMenu?.field === f.field}
1069
- {@const all = distinctValuesFor(f.field)}
1070
- <div class="pvd-popover pvd-popover-filter" data-pvd-menu={uid}>
1071
- <div class="pvd-popover-head">
1072
- <button type="button" class="pvd-popover-mini" onclick={() => setFilterAllowed(f.field, null)}>All</button>
1073
- <button type="button" class="pvd-popover-mini" onclick={() => setFilterAllowed(f.field, [])}>None</button>
1074
- </div>
1075
- <div class="pvd-popover-list">
1076
- {#each all as v (v)}
1077
- {@const checked = f.allowed == null || f.allowed.includes(v)}
1078
- <label class="pvd-popover-item pvd-popover-check">
1079
- <input type="checkbox" checked={checked}
1080
- onchange={() => {
1081
- const cur = f.allowed ?? all.slice()
1082
- const next = cur.includes(v) ? cur.filter((x) => x !== v) : [...cur, v]
1083
- setFilterAllowed(f.field, next.length === all.length ? null : next)
1084
- }} />
1085
- <span>{v}</span>
1086
- </label>
1087
- {/each}
1088
- </div>
1089
- </div>
1090
- {/if}
1091
- </div>
1092
- {/each}
1093
- {#if !layout.filters.length}<span class="pvd-well-hint">drop fields here to filter the source rows</span>{/if}
1094
- </div>
1095
- </div>
1096
- {/if}
1097
-
1098
- <!-- Columns -->
1099
- <div class="pvd-well"
1100
- class:drag-over={dragOver === 'cols'}
1101
- ondragover={(e) => onDragOver(e, 'cols')}
1102
- ondragleave={onDragLeave}
1103
- ondrop={(e) => onDrop(e, 'cols')}
1104
- oncontextmenu={(e) => openCtx(e, wellMenu('cols'))}>
1105
- <div class="pvd-well-head">{@render ic('columns')} Columns</div>
1106
- <div class="pvd-well-body">
1107
- {#each layout.cols as field (field)}
1108
- {@const fd = fieldsByName.get(field)}
1109
- <div class="pvd-chip" draggable="true" ondragstart={(e) => onDragStart(e, field, 'cols')} oncontextmenu={(e) => openCtx(e, chipMenu('cols', field))}>
1110
- <span class="pvd-chip-grip" aria-hidden="true">{@render ic('grip')}</span>
1111
- <span class="pvd-chip-label">{fd?.label ?? field}</span>
1112
- <button type="button" class="pvd-chip-x" onclick={() => removeFromWell(field, 'cols')} aria-label="Remove">×</button>
1113
- </div>
1114
- {/each}
1115
- {#if !layout.cols.length}<span class="pvd-well-hint">drop dimensions to pivot along the column axis</span>{/if}
1116
- </div>
1117
- </div>
1118
-
1119
- <!-- Rows -->
1120
- <div class="pvd-well"
1121
- class:drag-over={dragOver === 'rows'}
1122
- ondragover={(e) => onDragOver(e, 'rows')}
1123
- ondragleave={onDragLeave}
1124
- ondrop={(e) => onDrop(e, 'rows')}
1125
- oncontextmenu={(e) => openCtx(e, wellMenu('rows'))}>
1126
- <div class="pvd-well-head">{@render ic('rows')} Rows</div>
1127
- <div class="pvd-well-body">
1128
- {#each layout.rows as field (field)}
1129
- {@const fd = fieldsByName.get(field)}
1130
- <div class="pvd-chip" draggable="true" ondragstart={(e) => onDragStart(e, field, 'rows')} oncontextmenu={(e) => openCtx(e, chipMenu('rows', field))}>
1131
- <span class="pvd-chip-grip" aria-hidden="true">{@render ic('grip')}</span>
1132
- <span class="pvd-chip-label">{fd?.label ?? field}</span>
1133
- <button type="button" class="pvd-chip-x" onclick={() => removeFromWell(field, 'rows')} aria-label="Remove">×</button>
1134
- </div>
1135
- {/each}
1136
- {#if !layout.rows.length}<span class="pvd-well-hint">drop dimensions to group rows</span>{/if}
1137
- </div>
1138
- </div>
1139
-
1140
- <!-- Values -->
1141
- <div class="pvd-well"
1142
- class:drag-over={dragOver === 'values'}
1143
- ondragover={(e) => onDragOver(e, 'values')}
1144
- ondragleave={onDragLeave}
1145
- ondrop={(e) => onDrop(e, 'values')}
1146
- oncontextmenu={(e) => openCtx(e, wellMenu('values'))}>
1147
- <div class="pvd-well-head">{@render ic('sigma')} Values</div>
1148
- <div class="pvd-well-body">
1149
- {#each layout.values as v, vi (v.field + '|' + v.agg + '|' + vi)}
1150
- {@const fd = fieldsByName.get(v.field)}
1151
- <div class="pvd-chip pvd-chip-value" draggable="true" ondragstart={(e) => onDragStart(e, v.field, 'values', vi)} oncontextmenu={(e) => openCtx(e, chipMenu('values', v.field, vi))}>
1152
- <span class="pvd-chip-grip" aria-hidden="true">{@render ic('grip')}</span>
1153
- <button type="button" class="pvd-chip-label" onclick={() => toggleAggMenu(vi)}>
1154
- <span class="pvd-chip-agg">{AGG_LABEL[v.agg]}</span>
1155
- <span class="pvd-chip-sep">·</span>
1156
- <span>{fd?.label ?? v.field}</span>
1157
- </button>
1158
- <button type="button" class="pvd-chip-x" onclick={() => removeFromWell(v.field, 'values', vi)} aria-label="Remove">×</button>
1159
- {#if openMenu?.kind === 'agg' && openMenu.index === vi}
1160
- <div class="pvd-popover" data-pvd-menu={uid}>
1161
- {#each aggregators as agg (agg)}
1162
- <button
1163
- type="button"
1164
- class="pvd-popover-item"
1165
- class:is-active={v.agg === agg}
1166
- onclick={() => { setAggregatorAt(vi, agg); closeMenu() }}
1167
- >
1168
- {AGG_LABEL[agg]}
1169
- </button>
1170
- {/each}
1171
- </div>
1172
- {/if}
1173
- </div>
1174
- {/each}
1175
- {#if !layout.values.length}<span class="pvd-well-hint">drop measures to aggregate</span>{/if}
1176
- </div>
1177
- </div>
1178
- {/snippet}
1179
-
1180
- <!-- The Columns tab body: pivot toggle + field picker + the wells. -->
1181
- {#snippet columnsPanel()}
1182
- {#if showPivotToggle}{@render pivotToggle()}{/if}
1183
- {#if showFieldList}<div class="pvd-panel-rail">{@render railBlock()}</div>{/if}
1184
- <div class="pvd-wells pvd-wells--vertical" class:two={!showFiltersWell}>{@render wellsBlock()}</div>
1185
- {/snippet}
1186
-
1187
- <!-- The Filters tab body: AG-style set filters. Each active column filter is a
1188
- collapsible card with (Select All) + a searchable value checklist; the
1189
- "Add Filter" button opens a column picker to add another. Backed by the
1190
- same `layout.filters` the pivot uses, so filters narrow the source rows. -->
1191
- {#snippet filtersPanel()}
1192
- <div class="pvd-filters-tab">
1193
- {#each layout.filters as f (f.field)}
1194
- {@const fd = fieldsByName.get(f.field)}
1195
- {@const open = !filterCollapsed.has(f.field)}
1196
- {@const all = distinctValuesFor(f.field)}
1197
- {@const q = (filterSearch[f.field] ?? '').trim().toLowerCase()}
1198
- {@const shown = q ? all.filter((v) => v.toLowerCase().includes(q)) : all}
1199
- <div class="pvd-filt-card">
1200
- <div class="pvd-filt-head">
1201
- <button type="button" class="pvd-filt-title" onclick={() => toggleFilterCard(f.field)}>
1202
- <span class="pvd-filt-twisty" class:is-open={open}>{@render ic('chevron-down')}</span>
1203
- {fd?.label ?? f.field}{f.allowed ? ` (${f.allowed.length})` : ''}
1204
- </button>
1205
- <button type="button" class="pvd-filt-x" onclick={() => removeFromWell(f.field, 'filters')} aria-label={`Remove ${fd?.label ?? f.field} filter`}>×</button>
1206
- </div>
1207
- {#if open}
1208
- <input class="pvd-filt-search" type="search" placeholder="Search…" value={filterSearch[f.field] ?? ''} oninput={(e) => (filterSearch = { ...filterSearch, [f.field]: e.currentTarget.value })} />
1209
- <label class="pvd-filt-opt pvd-filt-all">
1210
- <input type="checkbox" checked={f.allowed == null} indeterminate={!!f.allowed && f.allowed.length > 0} onchange={() => setFilterAllowed(f.field, f.allowed == null ? [] : null)} />
1211
- <span>(Select All)</span>
1212
- </label>
1213
- <div class="pvd-filt-list">
1214
- {#each shown as v (v)}
1215
- <label class="pvd-filt-opt">
1216
- <input type="checkbox" checked={f.allowed == null || f.allowed.includes(v)} onchange={() => toggleFilterValue(f, v, all)} />
1217
- <span>{v}</span>
1218
- </label>
1219
- {/each}
1220
- {#if !shown.length}<div class="pvd-empty">No values match "{filterSearch[f.field]}"</div>{/if}
1221
- </div>
1222
- {/if}
1223
- </div>
1224
- {/each}
1225
-
1226
- <div class="pvd-addfilter" data-pvd-addfilter={uid}>
1227
- <button type="button" class="pvd-addfilter-btn" onclick={() => (addFilterOpen = !addFilterOpen)}>
1228
- {@render ic('addfilter')} Add Filter
1229
- </button>
1230
- {#if addFilterOpen}
1231
- <div class="pvd-addfilter-menu">
1232
- <div class="pvd-addfilter-searchwrap">
1233
- <span class="pvd-addfilter-searchicon">{@render ic('search')}</span>
1234
- <input class="pvd-addfilter-search" type="search" placeholder="Search columns…" bind:value={addFilterSearch} />
1235
- </div>
1236
- <div class="pvd-addfilter-list">
1237
- {#each availableFilterFields as f (f.field)}
1238
- <button type="button" class="pvd-addfilter-item" onclick={() => addFilterField(f.field)}>{f.label}</button>
1239
- {/each}
1240
- {#if !availableFilterFields.length}<div class="pvd-empty">No columns</div>{/if}
1241
- </div>
1242
- </div>
1243
- {/if}
1244
- </div>
1245
- </div>
1246
- {/snippet}
1247
-
1248
- {#snippet gridBlock()}
1249
- {#if embedGrid}
1250
- <div class="pvd-grid" style={`height:${typeof gridHeight === 'number' ? gridHeight + 'px' : gridHeight}`} oncontextmenu={(e) => openCtx(e, gridMenu())}>
1251
- {#if showPivotToggle && !pivotMode}
1252
- <SvGrid
1253
- data={filteredData}
1254
- columns={visibleFlatColumns!}
1255
- features={features}
1256
- filterMode="row"
1257
- sortable
1258
- selectionMode="none"
1259
- rowHeight={32}
1260
- containerHeight="100%"
1261
- fitColumns={gridFitColumns}
1262
- columnVirtualization={columnVirtualization}
1263
- enableRowSummaries={false}
1264
- />
1265
- {:else if pivot && chartable && view === 'chart'}
1266
- {#if chartSpec && chartSpec.series.length}
1267
- <div class="pvd-chart"><SvGridChart spec={chartSpec} interactive legend /></div>
1268
- {:else}
1269
- <div class="pvd-empty pvd-grid-empty">Add a measure and a row / column field to chart the pivot.</div>
1270
- {/if}
1271
- {:else if pivot}
1272
- <SvGrid
1273
- data={visibleRows}
1274
- columns={finalColumns}
1275
- features={features}
1276
- sortable
1277
- filterable
1278
- selectionMode="none"
1279
- rowHeight={32}
1280
- containerHeight="100%"
1281
- fitColumns={gridFitColumns}
1282
- columnVirtualization={columnVirtualization}
1283
- enableRowSummaries={false}
1284
- {onCellClick}
1285
- />
1286
- {:else}
1287
- <div class="pvd-empty pvd-grid-empty">
1288
- {#if !layout.values.length}
1289
- Drop at least one <strong>measure</strong> into Values to render the pivot.
1290
- {:else}
1291
- No data.
1292
- {/if}
1293
- </div>
1294
- {/if}
1295
- </div>
1296
- {/if}
1297
- {/snippet}
1298
-
1299
- <!-- Label cell renderer used when `expandable` is on. Indents by
1300
- pivot depth and shows a clickable chevron for expandable rows
1301
- (group rows that have descendants). -->
1302
- {#snippet ChevronLabelCell({ row, collapsed: isCollapsed, onToggle }: { row: PivotRow; collapsed: boolean; onToggle: () => void })}
1303
- <span class="pvd-label-cell" style={`padding-left:${row.__pivotDepth * 14}px`}>
1304
- {#if row.__pivotExpandable}
1305
- <button
1306
- type="button"
1307
- class="pvd-chev"
1308
- class:is-collapsed={isCollapsed}
1309
- onclick={(e) => { e.stopPropagation(); onToggle() }}
1310
- aria-label={isCollapsed ? 'Expand' : 'Collapse'}
1311
- >▾</button>
1312
- {:else}
1313
- <span class="pvd-chev pvd-chev-placeholder"></span>
1314
- {/if}
1315
- <span class="pvd-label-text" class:is-subtotal={row.__pivotKind === 'subtotal'} class:is-grand={row.__pivotKind === 'grandTotal'}>
1316
- {row.__pivotLabel}
1317
- </span>
1318
- </span>
1319
- {/snippet}
1320
-
1321
- <style>
1322
- /* SvPivotDesigner styles -------------------------------------------
1323
- All variables use the SvGrid token system (--sg-*) with safe
1324
- fallbacks so the component theme-matches whatever grid skin the
1325
- host page is using. */
1326
- .pvd {
1327
- display: flex;
1328
- flex-direction: column;
1329
- width: 100%;
1330
- height: 100%;
1331
- min-height: 0;
1332
- color: var(--sg-fg, #0f172a);
1333
- background: var(--sg-bg, #ffffff);
1334
- border: 1px solid var(--sg-border, #e2e8f0);
1335
- border-radius: 8px;
1336
- overflow: hidden;
1337
- font-family: inherit;
1338
- }
1339
- .pvd-toolbar {
1340
- display: flex;
1341
- align-items: center;
1342
- gap: 8px;
1343
- padding: 8px 12px;
1344
- background: var(--sg-header-bg, #f8fafc);
1345
- border-bottom: 1px solid var(--sg-border, #e2e8f0);
1346
- flex-shrink: 0;
1347
- }
1348
- .pvd-title {
1349
- font-size: 13px;
1350
- font-weight: 700;
1351
- color: var(--sg-fg);
1352
- margin-right: 8px;
1353
- }
1354
- .pvd-spacer { flex: 1; }
1355
- .pvd-btn {
1356
- display: inline-flex;
1357
- align-items: center;
1358
- gap: 5px;
1359
- border: 1px solid var(--sg-border, #cbd5e1);
1360
- background: var(--sg-bg, #ffffff);
1361
- color: var(--sg-fg, #1e293b);
1362
- padding: 4px 10px;
1363
- border-radius: 5px;
1364
- font-size: 12px;
1365
- font-weight: 600;
1366
- cursor: pointer;
1367
- transition: background 100ms ease, border-color 100ms ease;
1368
- }
1369
- .pvd-btn .pvd-ic { color: var(--sg-muted, #64748b); }
1370
- .pvd-btn-primary .pvd-ic { color: currentColor; }
1371
- .pvd-btn:hover { background: var(--sg-row-hover-bg, #f1f5f9); border-color: var(--sg-accent, #2563eb); }
1372
- .pvd-btn-primary {
1373
- background: var(--sg-accent, #2563eb);
1374
- color: #fff;
1375
- border-color: var(--sg-accent, #2563eb);
1376
- }
1377
- .pvd-btn-primary:hover { opacity: 0.9; }
1378
- .pvd-toggle {
1379
- display: inline-flex;
1380
- align-items: center;
1381
- gap: 4px;
1382
- font-size: 12px;
1383
- color: var(--sg-fg);
1384
- cursor: pointer;
1385
- user-select: none;
1386
- }
1387
- .pvd-toggle input { accent-color: var(--sg-accent, #2563eb); }
1388
-
1389
- .pvd-body {
1390
- display: grid;
1391
- grid-template-columns: 220px 1fr;
1392
- gap: 0;
1393
- flex: 1;
1394
- min-height: 0;
1395
- }
1396
- .pvd-body.no-rail { grid-template-columns: 1fr; }
1397
-
1398
- /* Left rail */
1399
- .pvd-rail {
1400
- display: flex;
1401
- flex-direction: column;
1402
- border-right: 1px solid var(--sg-border, #e2e8f0);
1403
- background: var(--sg-bg, #ffffff);
1404
- min-height: 0;
1405
- }
1406
- .pvd-search {
1407
- border: 0;
1408
- border-bottom: 1px solid var(--sg-border, #e2e8f0);
1409
- padding: 8px 12px;
1410
- font-size: 12px;
1411
- background: transparent;
1412
- color: var(--sg-fg);
1413
- outline: none;
1414
- }
1415
- .pvd-search:focus { background: var(--sg-row-hover-bg, #f1f5f9); }
1416
- .pvd-fieldlist {
1417
- flex: 1;
1418
- min-height: 0;
1419
- overflow: auto;
1420
- padding: 4px 0;
1421
- }
1422
- .pvd-group-head {
1423
- padding: 8px 12px 4px;
1424
- font-size: 10.5px;
1425
- font-weight: 700;
1426
- text-transform: uppercase;
1427
- letter-spacing: 0.06em;
1428
- color: var(--sg-muted, #64748b);
1429
- }
1430
- .pvd-field {
1431
- display: grid;
1432
- grid-template-columns: 16px auto 1fr;
1433
- align-items: center;
1434
- gap: 8px;
1435
- padding: 5px 12px;
1436
- font-size: 12px;
1437
- cursor: grab;
1438
- transition: background 80ms ease;
1439
- }
1440
- .pvd-field:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
1441
- .pvd-field:active { cursor: grabbing; }
1442
- .pvd-field.in-use .pvd-field-label { font-weight: 600; color: var(--sg-accent, #2563eb); }
1443
- .pvd-field-label {
1444
- overflow: hidden;
1445
- text-overflow: ellipsis;
1446
- white-space: nowrap;
1447
- }
1448
- /* Dimension vs measure indicator - a tinted icon puck. */
1449
- .pvd-field-kind {
1450
- display: inline-flex;
1451
- align-items: center;
1452
- justify-content: center;
1453
- width: 20px;
1454
- height: 20px;
1455
- border-radius: 4px;
1456
- color: var(--sg-accent, #2563eb);
1457
- background: color-mix(in srgb, var(--sg-accent, #2563eb) 12%, transparent);
1458
- }
1459
- .pvd-field-kind.is-measure {
1460
- color: #0e9f6e;
1461
- background: color-mix(in srgb, #0e9f6e 12%, transparent);
1462
- }
1463
-
1464
- /* Base icon sizing (inline SVGs from the `ic` snippet). */
1465
- :global(.pvd .pvd-ic),
1466
- :global(.pvd-ctx .pvd-ic) {
1467
- width: 14px;
1468
- height: 14px;
1469
- flex: none;
1470
- display: block;
1471
- }
1472
-
1473
- /* ---- Columns tool panel: visibility tree (columnTree) ------------ */
1474
- .pvd-tree-top {
1475
- display: flex;
1476
- align-items: center;
1477
- gap: 8px;
1478
- padding: 10px 12px 6px;
1479
- }
1480
- .pvd-tree-search {
1481
- flex: 1;
1482
- border: 1px solid var(--sg-input-border, var(--sg-border, #e2e8f0));
1483
- border-radius: var(--sg-radius, 6px);
1484
- padding: 6px 9px;
1485
- }
1486
- .pvd-tree-check { accent-color: var(--sg-accent, #2563eb); width: 14px; height: 14px; flex: none; cursor: pointer; }
1487
- .pvd-tree { padding: 2px 0 6px; }
1488
- .pvd-tree-group {
1489
- display: flex;
1490
- align-items: center;
1491
- gap: 6px;
1492
- padding: 4px 12px 4px 6px;
1493
- cursor: default;
1494
- }
1495
- .pvd-tree-group:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
1496
- .pvd-tree-group-name {
1497
- font-size: 12px;
1498
- font-weight: 700;
1499
- color: var(--sg-fg, #0f172a);
1500
- overflow: hidden;
1501
- text-overflow: ellipsis;
1502
- white-space: nowrap;
1503
- }
1504
- .pvd-tree-twisty {
1505
- display: inline-flex;
1506
- align-items: center;
1507
- justify-content: center;
1508
- width: 16px;
1509
- height: 16px;
1510
- padding: 0;
1511
- border: 0;
1512
- background: transparent;
1513
- color: var(--sg-muted, #64748b);
1514
- cursor: pointer;
1515
- transition: transform 100ms ease;
1516
- }
1517
- .pvd-tree-twisty:not(.is-open) { transform: rotate(-90deg); }
1518
- .pvd-tree-leaf {
1519
- display: flex;
1520
- align-items: center;
1521
- gap: 8px;
1522
- /* Indent past the twisty so leaves nest under their group. */
1523
- padding: 4px 12px 4px 30px;
1524
- font-size: 12px;
1525
- cursor: grab;
1526
- transition: background 80ms ease;
1527
- }
1528
- .pvd-tree-leaf:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
1529
- .pvd-tree-leaf:active { cursor: grabbing; }
1530
- .pvd-tree-leaf .pvd-field-label { flex: 1; }
1531
- .pvd-tree .pvd-grip { display: inline-flex; align-items: center; color: var(--sg-muted, #cbd5e1); }
1532
- .pvd-tree .pvd-grip .pvd-ic { width: 12px; height: 12px; }
1533
-
1534
- /* Main area */
1535
- .pvd-main {
1536
- display: flex;
1537
- flex-direction: column;
1538
- min-height: 0;
1539
- min-width: 0;
1540
- }
1541
- .pvd-wells {
1542
- display: grid;
1543
- grid-template-columns: repeat(4, 1fr);
1544
- gap: 8px;
1545
- padding: 8px;
1546
- background: var(--sg-bg, #ffffff);
1547
- border-bottom: 1px solid var(--sg-border, #e2e8f0);
1548
- }
1549
- .pvd-wells.two { grid-template-columns: repeat(3, 1fr); }
1550
-
1551
- .pvd-well {
1552
- display: flex;
1553
- flex-direction: column;
1554
- border: 1px dashed var(--sg-border, #cbd5e1);
1555
- border-radius: 6px;
1556
- background: var(--sg-bg, #ffffff);
1557
- min-height: 64px;
1558
- transition: border-color 100ms ease, background 100ms ease;
1559
- }
1560
- .pvd-well.drag-over {
1561
- border-color: var(--sg-accent, #2563eb);
1562
- border-style: solid;
1563
- background: color-mix(in srgb, var(--sg-accent, #2563eb) 8%, var(--sg-bg, #ffffff));
1564
- }
1565
- .pvd-well-head {
1566
- display: flex;
1567
- align-items: center;
1568
- gap: 5px;
1569
- padding: 4px 8px 2px;
1570
- font-size: 10.5px;
1571
- font-weight: 700;
1572
- text-transform: uppercase;
1573
- letter-spacing: 0.06em;
1574
- color: var(--sg-muted, #64748b);
1575
- }
1576
- .pvd-well-head .pvd-ic { color: var(--sg-accent, #2563eb); }
1577
- .pvd-well-body {
1578
- display: flex;
1579
- flex-wrap: wrap;
1580
- gap: 4px;
1581
- padding: 4px 8px 8px;
1582
- min-height: 36px;
1583
- align-content: flex-start;
1584
- }
1585
- .pvd-well-hint {
1586
- color: var(--sg-muted, #94a3b8);
1587
- font-size: 11px;
1588
- font-style: italic;
1589
- padding: 4px 0;
1590
- }
1591
-
1592
- .pvd-chip {
1593
- position: relative;
1594
- display: inline-flex;
1595
- align-items: center;
1596
- gap: 2px;
1597
- background: var(--sg-header-bg, #f1f5f9);
1598
- border: 1px solid var(--sg-border, #cbd5e1);
1599
- border-radius: 5px;
1600
- font-size: 12px;
1601
- color: var(--sg-fg, #0f172a);
1602
- cursor: grab;
1603
- user-select: none;
1604
- }
1605
- .pvd-chip:active { cursor: grabbing; }
1606
- .pvd-chip-value {
1607
- background: color-mix(in srgb, var(--sg-accent, #2563eb) 14%, var(--sg-bg, #ffffff));
1608
- border-color: var(--sg-accent, #2563eb);
1609
- }
1610
- .pvd-chip-grip {
1611
- display: inline-flex;
1612
- align-items: center;
1613
- padding-left: 4px;
1614
- color: var(--sg-muted, #94a3b8);
1615
- cursor: grab;
1616
- }
1617
- .pvd-chip-grip .pvd-ic { width: 12px; height: 12px; }
1618
- .pvd-chip-label {
1619
- border: 0;
1620
- background: transparent;
1621
- padding: 4px 6px 4px 4px;
1622
- font: inherit;
1623
- color: inherit;
1624
- cursor: pointer;
1625
- }
1626
- .pvd-chip-agg {
1627
- font-size: 10.5px;
1628
- font-weight: 700;
1629
- color: var(--sg-accent, #2563eb);
1630
- text-transform: uppercase;
1631
- letter-spacing: 0.04em;
1632
- margin-right: 4px;
1633
- }
1634
- .pvd-chip-sep { color: var(--sg-muted, #94a3b8); margin-right: 4px; }
1635
- .pvd-chip-x {
1636
- border: 0;
1637
- background: transparent;
1638
- color: var(--sg-muted, #64748b);
1639
- cursor: pointer;
1640
- padding: 2px 6px 3px;
1641
- font-size: 14px;
1642
- line-height: 1;
1643
- border-radius: 3px;
1644
- }
1645
- .pvd-chip-x:hover { color: #ef4444; background: rgba(239, 68, 68, 0.08); }
1646
-
1647
- /* Popovers (agg menu, filter menu, presets) */
1648
- .pvd-popover, .pvd-presets {
1649
- position: relative;
1650
- }
1651
- .pvd-popover {
1652
- position: absolute;
1653
- top: calc(100% + 4px);
1654
- left: 0;
1655
- z-index: 50;
1656
- min-width: 160px;
1657
- background: var(--sg-bg, #ffffff);
1658
- border: 1px solid var(--sg-border, #cbd5e1);
1659
- border-radius: 6px;
1660
- box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
1661
- padding: 4px;
1662
- }
1663
- .pvd-popover-filter { min-width: 200px; max-height: 280px; display: flex; flex-direction: column; }
1664
- .pvd-popover-head {
1665
- display: flex; gap: 4px;
1666
- padding: 4px;
1667
- border-bottom: 1px solid var(--sg-border, #e2e8f0);
1668
- }
1669
- .pvd-popover-mini {
1670
- flex: 1;
1671
- border: 1px solid var(--sg-border, #cbd5e1);
1672
- background: var(--sg-bg, #ffffff);
1673
- color: var(--sg-fg);
1674
- padding: 2px 6px;
1675
- border-radius: 4px;
1676
- font-size: 11px;
1677
- cursor: pointer;
1678
- }
1679
- .pvd-popover-mini:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
1680
- .pvd-popover-list { flex: 1; min-height: 0; overflow: auto; padding: 4px 0; }
1681
- .pvd-popover-item {
1682
- display: block;
1683
- width: 100%;
1684
- text-align: left;
1685
- border: 0;
1686
- background: transparent;
1687
- padding: 5px 10px;
1688
- font-size: 12px;
1689
- color: var(--sg-fg);
1690
- cursor: pointer;
1691
- border-radius: 4px;
1692
- }
1693
- .pvd-popover-item:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
1694
- .pvd-popover-item.is-active { background: color-mix(in srgb, var(--sg-accent, #2563eb) 14%, transparent); color: var(--sg-accent, #2563eb); font-weight: 600; }
1695
- .pvd-popover-check {
1696
- display: flex; align-items: center; gap: 8px;
1697
- cursor: pointer;
1698
- }
1699
- .pvd-popover-check input { accent-color: var(--sg-accent, #2563eb); }
1700
-
1701
- /* Embedded grid */
1702
- .pvd-grid {
1703
- flex: 1;
1704
- min-height: 0;
1705
- padding: 0;
1706
- }
1707
- .pvd-grid-empty {
1708
- display: flex;
1709
- align-items: center;
1710
- justify-content: center;
1711
- height: 100%;
1712
- color: var(--sg-muted, #94a3b8);
1713
- font-size: 13px;
1714
- padding: 24px;
1715
- text-align: center;
1716
- }
1717
-
1718
- .pvd-empty {
1719
- padding: 20px;
1720
- color: var(--sg-muted, #94a3b8);
1721
- font-size: 12px;
1722
- text-align: center;
1723
- }
1724
-
1725
- /* Built-in label cell renderer (when expandable is on) */
1726
- :global(.pvd-label-cell) {
1727
- display: inline-flex;
1728
- align-items: center;
1729
- gap: 4px;
1730
- line-height: 1;
1731
- }
1732
- :global(.pvd-chev) {
1733
- width: 14px;
1734
- height: 14px;
1735
- border: 0;
1736
- background: transparent;
1737
- color: var(--sg-muted, #64748b);
1738
- font-size: 10px;
1739
- line-height: 14px;
1740
- cursor: pointer;
1741
- border-radius: 3px;
1742
- padding: 0;
1743
- transition: transform 100ms ease, background 100ms ease;
1744
- }
1745
- :global(.pvd-chev:hover) { background: var(--sg-row-hover-bg, #f1f5f9); color: var(--sg-fg, #0f172a); }
1746
- :global(.pvd-chev.is-collapsed) { transform: rotate(-90deg); }
1747
- :global(.pvd-chev-placeholder) { cursor: default; visibility: hidden; }
1748
- :global(.pvd-label-text.is-subtotal) { font-weight: 700; }
1749
- :global(.pvd-label-text.is-grand) { font-weight: 800; color: var(--sg-accent, #2563eb); }
1750
-
1751
- /* Mobile: stack rail above main, wells in two columns. */
1752
- @media (max-width: 900px) {
1753
- .pvd-body { grid-template-columns: 1fr; }
1754
- .pvd-rail {
1755
- border-right: 0;
1756
- border-bottom: 1px solid var(--sg-border, #e2e8f0);
1757
- max-height: 200px;
1758
- }
1759
- .pvd-wells, .pvd-wells.two { grid-template-columns: repeat(2, 1fr); }
1760
- }
1761
- .pvd-chip-caret { font-size: 9px; color: var(--sg-muted, #94a3b8); }
1762
- .pvd-select {
1763
- font: inherit; font-size: 12px; color: var(--sg-fg);
1764
- background: var(--sg-input-bg, var(--sg-bg, #fff));
1765
- border: 1px solid var(--sg-input-border, var(--sg-border, #e2e8f0));
1766
- border-radius: var(--sg-radius, 6px); padding: 3px 6px;
1767
- }
1768
- .pvd-viewswitch { display: inline-flex; border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius, 6px); overflow: hidden; }
1769
- .pvd-view-btn { display: inline-flex; align-items: center; gap: 5px; font: inherit; font-size: 12px; padding: 3px 12px; border: 0; background: var(--sg-bg, #fff); color: var(--sg-muted, #64748b); cursor: pointer; }
1770
- .pvd-view-btn.is-active { background: var(--sg-accent, #2563eb); color: #fff; }
1771
- .pvd-chart { width: 100%; height: 100%; padding: 8px 10px; box-sizing: border-box; overflow: hidden; display: flex; }
1772
- .pvd-chart > :global(.sv-grid-chart) { width: 100%; }
1773
-
1774
- /* ---- Right-docked tool panel (panelPosition="right") -------------- */
1775
- .pvd-body[data-panel='right'] {
1776
- display: flex;
1777
- flex-direction: row;
1778
- }
1779
- .pvd-gridwrap {
1780
- flex: 1;
1781
- min-width: 0;
1782
- min-height: 0;
1783
- display: flex;
1784
- flex-direction: column;
1785
- }
1786
- .pvd-gridwrap .pvd-grid { flex: 1; min-height: 0; }
1787
- .pvd-panel {
1788
- flex-shrink: 0;
1789
- display: flex;
1790
- flex-direction: column;
1791
- min-height: 0;
1792
- border-left: 1px solid var(--sg-border, #e2e8f0);
1793
- background: var(--sg-header-bg, #f8fafc);
1794
- overflow-y: auto;
1795
- }
1796
- /* Tabbed panel: content column + a vertical Columns/Filters rail on the right. */
1797
- .pvd-panel-tabbed {
1798
- flex-direction: row;
1799
- overflow: hidden;
1800
- }
1801
- .pvd-tabbody {
1802
- flex: 1;
1803
- min-width: 0;
1804
- min-height: 0;
1805
- display: flex;
1806
- flex-direction: column;
1807
- overflow-y: auto;
1808
- }
1809
- .pvd-tabrail {
1810
- flex: none;
1811
- display: flex;
1812
- flex-direction: column;
1813
- border-left: 1px solid var(--sg-border, #e2e8f0);
1814
- background: var(--sg-bg, #fff);
1815
- }
1816
- .pvd-tabbtn {
1817
- display: flex;
1818
- flex-direction: column;
1819
- align-items: center;
1820
- gap: 6px;
1821
- padding: 12px 7px;
1822
- border: 0;
1823
- border-left: 2px solid transparent;
1824
- background: transparent;
1825
- color: var(--sg-muted, #64748b);
1826
- cursor: pointer;
1827
- font-size: 11.5px;
1828
- font-weight: 600;
1829
- }
1830
- .pvd-tabbtn span { writing-mode: vertical-rl; }
1831
- .pvd-tabbtn:hover { color: var(--sg-fg, #0f172a); background: var(--sg-row-hover-bg, #f1f5f9); }
1832
- .pvd-tabbtn.is-active {
1833
- color: var(--sg-accent, #2563eb);
1834
- border-left-color: var(--sg-accent, #2563eb);
1835
- background: var(--sg-header-bg, #f8fafc);
1836
- }
1837
- .pvd-tabbtn.is-active .pvd-ic { color: var(--sg-accent, #2563eb); }
1838
-
1839
- /* ---- Filters tab (set filters) ---- */
1840
- .pvd-filters-tab { display: flex; flex-direction: column; gap: 8px; padding: 10px; }
1841
- .pvd-filt-card {
1842
- border: 1px solid var(--sg-border, #e2e8f0);
1843
- border-radius: 8px;
1844
- background: var(--sg-bg, #fff);
1845
- overflow: hidden;
1846
- }
1847
- .pvd-filt-head {
1848
- display: flex; align-items: center;
1849
- border-bottom: 1px solid var(--sg-border, #e2e8f0);
1850
- }
1851
- .pvd-filt-title {
1852
- flex: 1; min-width: 0;
1853
- display: inline-flex; align-items: center; gap: 5px;
1854
- padding: 7px 8px;
1855
- border: 0; background: transparent;
1856
- font-size: 12.5px; font-weight: 600; color: var(--sg-fg, #0f172a);
1857
- cursor: pointer; text-align: left;
1858
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1859
- }
1860
- .pvd-filt-twisty { display: inline-flex; color: var(--sg-muted, #64748b); transition: transform 100ms ease; }
1861
- .pvd-filt-twisty:not(.is-open) { transform: rotate(-90deg); }
1862
- .pvd-filt-x {
1863
- border: 0; background: transparent; color: var(--sg-muted, #94a3b8);
1864
- cursor: pointer; font-size: 15px; line-height: 1; padding: 4px 9px;
1865
- }
1866
- .pvd-filt-x:hover { color: #ef4444; }
1867
- .pvd-filt-search {
1868
- width: 100%; box-sizing: border-box;
1869
- border: 0; border-bottom: 1px solid var(--sg-border, #e2e8f0);
1870
- padding: 7px 9px; font-size: 12px; background: transparent; color: var(--sg-fg, #0f172a);
1871
- outline: none;
1872
- }
1873
- .pvd-filt-list { max-height: 220px; overflow-y: auto; }
1874
- .pvd-filt-opt {
1875
- display: flex; align-items: center; gap: 8px;
1876
- padding: 4px 10px; font-size: 12.5px; color: var(--sg-fg, #0f172a); cursor: pointer;
1877
- }
1878
- .pvd-filt-opt:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
1879
- .pvd-filt-opt span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1880
- .pvd-filt-opt input { accent-color: var(--sg-accent, #2563eb); flex: none; }
1881
- .pvd-filt-all { font-weight: 600; border-bottom: 1px solid var(--sg-border, #e2e8f0); }
1882
-
1883
- .pvd-addfilter { position: relative; }
1884
- .pvd-addfilter-btn {
1885
- display: inline-flex; align-items: center; gap: 6px;
1886
- width: 100%; box-sizing: border-box;
1887
- padding: 8px 10px;
1888
- border: 1px solid var(--sg-border, #cbd5e1); border-radius: 8px;
1889
- background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
1890
- font-size: 12.5px; font-weight: 600; cursor: pointer;
1891
- }
1892
- .pvd-addfilter-btn:hover { border-color: var(--sg-accent, #2563eb); background: var(--sg-row-hover-bg, #f1f5f9); }
1893
- .pvd-addfilter-btn .pvd-ic { color: var(--sg-muted, #64748b); }
1894
- .pvd-addfilter-menu {
1895
- position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 60;
1896
- background: var(--sg-bg, #fff);
1897
- border: 1px solid var(--sg-border, #cbd5e1); border-radius: 8px;
1898
- box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
1899
- overflow: hidden;
1900
- }
1901
- .pvd-addfilter-searchwrap {
1902
- display: flex; align-items: center; gap: 6px;
1903
- padding: 7px 9px; border-bottom: 1px solid var(--sg-border, #e2e8f0);
1904
- color: var(--sg-muted, #94a3b8);
1905
- }
1906
- .pvd-addfilter-search { flex: 1; border: 0; outline: none; background: transparent; color: var(--sg-fg, #0f172a); font-size: 12px; }
1907
- .pvd-addfilter-list { max-height: 240px; overflow-y: auto; padding: 4px 0; }
1908
- .pvd-addfilter-item {
1909
- display: block; width: 100%; text-align: left;
1910
- border: 0; background: transparent; color: var(--sg-fg, #0f172a);
1911
- padding: 6px 12px; font-size: 12.5px; cursor: pointer;
1912
- }
1913
- .pvd-addfilter-item:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
1914
- .pvd-panel-rail {
1915
- display: flex;
1916
- flex-direction: column;
1917
- flex: 1 1 auto;
1918
- min-height: 120px;
1919
- border-bottom: 1px solid var(--sg-border, #e2e8f0);
1920
- }
1921
- .pvd-panel-rail .pvd-search {
1922
- background: var(--sg-input-bg, var(--sg-bg, #fff));
1923
- border: 1px solid var(--sg-input-border, var(--sg-border, #e2e8f0));
1924
- border-radius: var(--sg-radius, 6px);
1925
- margin: 10px 10px 6px;
1926
- padding: 6px 9px;
1927
- }
1928
- .pvd-panel-rail .pvd-fieldlist { padding-bottom: 8px; }
1929
- .pvd-wells--vertical {
1930
- display: flex;
1931
- flex-direction: column;
1932
- gap: 8px;
1933
- padding: 10px;
1934
- border-bottom: 0;
1935
- flex: 0 0 auto;
1936
- }
1937
- .pvd-wells--vertical .pvd-well { min-height: 56px; background: var(--sg-bg, #fff); }
1938
-
1939
- /* ---- Pivot Mode toggle ------------------------------------------- */
1940
- .pvd-topbar {
1941
- padding: 8px 10px;
1942
- border-bottom: 1px solid var(--sg-border, #e2e8f0);
1943
- background: var(--sg-header-bg, #f8fafc);
1944
- }
1945
- .pvd-panel > .pvd-pivot-toggle {
1946
- padding: 12px;
1947
- border-bottom: 1px solid var(--sg-border, #e2e8f0);
1948
- }
1949
- .pvd-pivot-toggle {
1950
- display: flex;
1951
- align-items: center;
1952
- gap: 8px;
1953
- cursor: pointer;
1954
- user-select: none;
1955
- }
1956
- .pvd-pivot-toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
1957
- .pvd-switch {
1958
- width: 34px; height: 18px; border-radius: 999px; flex-shrink: 0;
1959
- background: var(--sg-border, #cbd5e1);
1960
- position: relative; transition: background 120ms ease;
1961
- }
1962
- .pvd-switch.on { background: var(--sg-accent, #2563eb); }
1963
- .pvd-switch-knob {
1964
- position: absolute; top: 2px; left: 2px;
1965
- width: 14px; height: 14px; border-radius: 999px; background: #fff;
1966
- transition: transform 120ms ease;
1967
- }
1968
- .pvd-switch.on .pvd-switch-knob { transform: translateX(16px); }
1969
- .pvd-pivot-icon { display: inline-flex; align-items: center; color: var(--sg-accent, #2563eb); }
1970
- .pvd-pivot-label { font-size: 13px; font-weight: 600; color: var(--sg-fg, #0f172a); }
1971
-
1972
- /* ---- Right-click context menu panel ------------------------------ */
1973
- :global(.pvd-ctx) {
1974
- z-index: 2147483646;
1975
- min-width: 200px;
1976
- background: var(--sg-bg, #fff);
1977
- color: var(--sg-fg, #0f172a);
1978
- border: 1px solid var(--sg-border, #e2e8f0);
1979
- border-radius: 10px;
1980
- box-shadow: 0 16px 48px -12px rgba(15, 23, 42, 0.35);
1981
- font-size: 13px;
1982
- }
1983
-
1984
- @media (max-width: 900px) {
1985
- .pvd-body[data-panel='right'] { flex-direction: column; }
1986
- .pvd-panel { width: auto !important; border-left: 0; border-top: 1px solid var(--sg-border, #e2e8f0); }
1987
- .pvd-wells--vertical { flex-direction: row; flex-wrap: wrap; }
1988
- .pvd-wells--vertical .pvd-well { flex: 1 1 40%; }
1989
- }
1990
- </style>
1
+ <script lang="ts" module>
2
+ type PivotInputRow = Record<string, unknown>
3
+ </script>
4
+ <script lang="ts" generics="T extends PivotInputRow">
5
+ /**
6
+ * SvPivotDesigner - Excel-style pivot table designer.
7
+ *
8
+ * Self-contained, controlled component. The consumer holds the data
9
+ * + the field list + the layout state; everything else (drag-and-drop,
10
+ * chip menus, search, presets, the inline pivot grid) is built in.
11
+ *
12
+ * Minimal wire-up (in a .svelte file):
13
+ * const fields = [
14
+ * { field: 'region', label: 'Region', kind: 'dimension' },
15
+ * { field: 'quarter', label: 'Quarter', kind: 'dimension' },
16
+ * { field: 'amount', label: 'Revenue', kind: 'measure', defaultAgg: 'sum' },
17
+ * ]
18
+ * let layout = $state(defaultLayoutFor(fields))
19
+ *
20
+ * SvPivotDesigner {data} {fields} bind:layout
21
+ */
22
+ import {
23
+ SvGrid,
24
+ SvGridChart,
25
+ SvMenuList,
26
+ tableFeatures,
27
+ rowSortingFeature,
28
+ columnFilteringFeature,
29
+ renderSnippet,
30
+ portalToBody,
31
+ popIn,
32
+ createDismissableLayer,
33
+ createFocusTrap,
34
+ onScrollOutside,
35
+ type ColumnDef,
36
+ type ChartType,
37
+ type MenuItem,
38
+ } from '@svgrid/grid'
39
+ import { createPivotModel, filterCollapsedPivotRows, type PivotRow, type PivotAggregatorId } from './pivot'
40
+ import { pivotToChartSpec } from './pivot-chart'
41
+ import {
42
+ ALL_AGGREGATORS, AGG_LABEL, EMPTY_LAYOUT, defaultLayoutFor,
43
+ type PivotField, type PivotLayout, type PivotPreset, type Well,
44
+ } from './pivot-designer'
45
+
46
+ type Props = {
47
+ /** Flat input rows. */
48
+ data: T[]
49
+ /** All fields the user can pick from the rail. */
50
+ fields: PivotField<T>[]
51
+ /** The current pivot layout. Bindable so the consumer can persist it. */
52
+ layout?: PivotLayout
53
+ /** Fired when the user changes the layout (drag, drop, chip menu, …). */
54
+ onLayoutChange?: (layout: PivotLayout) => void
55
+
56
+ // ---- Optional features --------------------------------------------
57
+ /** Saved layouts surfaced in the toolbar's Presets menu. */
58
+ presets?: PivotPreset[]
59
+ /** Aggregators offered in the Values chip menu. Default: all. */
60
+ aggregators?: PivotAggregatorId[]
61
+ /** Show the toolbar above the wells. Default true. */
62
+ showToolbar?: boolean
63
+ /** Show the left-rail field picker. Default true. */
64
+ showFieldList?: boolean
65
+ /** Show the Filters well. Default true. */
66
+ showFiltersWell?: boolean
67
+ /** Custom Export handler. When set, an Export button appears. */
68
+ onExport?: (layout: PivotLayout, rows: PivotRow[]) => void
69
+ /** Height of the inner pivot grid. Default '100%'. */
70
+ gridHeight?: string | number
71
+ /** Render the embedded grid? Set false to host it separately and
72
+ * read `pivot` from the on:pivot event. Default true. */
73
+ embedGrid?: boolean
74
+ /** Fires whenever the underlying pivot model rebuilds. */
75
+ onPivot?: (rows: PivotRow[], columns: ColumnDef<typeof features, PivotRow>[]) => void
76
+ /** Allow row-level expand / collapse. When true, an expand chevron
77
+ * appears in the label cell on every `group` row and clicking it
78
+ * toggles which descendant rows are visible. The designer manages
79
+ * the collapsed set internally. Default false. */
80
+ expandable?: boolean
81
+ /** Transform the generated column tree right before the grid renders.
82
+ * The consumer can attach custom `cell:` / `header:` / `cellClass:`
83
+ * properties, change widths, etc. Receives the full tree + the
84
+ * current layout (so it can branch on which measures are present). */
85
+ decorateColumns?: (
86
+ cols: ColumnDef<typeof features, PivotRow>[],
87
+ layout: PivotLayout,
88
+ ) => ColumnDef<typeof features, PivotRow>[]
89
+ /** Click handler forwarded to the embedded SvGrid - typically used
90
+ * to drive a drill-through side panel. */
91
+ onCellClick?: (ctx: { columnId: string; row: PivotRow; value: unknown }) => void
92
+ /** Offer a Table <-> Chart view toggle (same layout as a live chart). Default true. */
93
+ chartable?: boolean
94
+ /** Which view to show first when `chartable`. Default 'table'. */
95
+ defaultView?: 'table' | 'chart'
96
+
97
+ // ---- Presentation -------------------------------------------------
98
+ /**
99
+ * Where the authoring UI (field picker + wells) lives relative to the
100
+ * grid. `'top'` (default) keeps the classic Excel layout: left rail +
101
+ * a horizontal row of wells above the grid. `'right'` docks everything
102
+ * as a single vertical tool panel on the right of the grid - the
103
+ * conventional "enterprise data grid" pivot-panel arrangement.
104
+ */
105
+ panelPosition?: 'top' | 'right'
106
+ /** Width of the docked panel when `panelPosition='right'`. Default 280. */
107
+ panelWidth?: number | string
108
+ /**
109
+ * Enables a "Pivot Mode" toggle. When OFF the embedded grid renders the
110
+ * flat source rows with `flatColumns`; when ON it renders the pivot.
111
+ * Requires `flatColumns` to be provided. Bindable.
112
+ */
113
+ pivotMode?: boolean
114
+ /** Columns for the flat (pivot-off) grid. Enables the Pivot Mode toggle. */
115
+ flatColumns?: ColumnDef<typeof features, T>[]
116
+ /** Fired when the Pivot Mode toggle changes. */
117
+ onPivotModeChange?: (on: boolean) => void
118
+ /** Fit the embedded grid's columns to width. Default true. Set false to
119
+ * let a wide pivot scroll horizontally (recommended with many columns). */
120
+ gridFitColumns?: boolean
121
+ /** Enable the right-click context menu on field-list rows, well chips, and
122
+ * the pivot grid (add/move/remove fields, change aggregator, expand /
123
+ * collapse). Default true. */
124
+ contextMenu?: boolean
125
+ /**
126
+ * Column virtualization on the embedded grid. Default true. Set FALSE for
127
+ * wide pivots with grouped column headers: the grouped header band isn't
128
+ * virtualized, so a virtualized body leaves off-window group columns
129
+ * looking empty until scrolled. Disabling it renders every column, so
130
+ * header and body always stay in sync (fine up to a few hundred columns).
131
+ */
132
+ columnVirtualization?: boolean
133
+ /**
134
+ * Render the field picker as a **Columns** tool panel: a
135
+ * collapsible column-group TREE whose checkboxes toggle column VISIBILITY
136
+ * (with a select-all), instead of the flat "tick to add to a well" list.
137
+ * Fields still drag into the Rows / Columns / Values wells. The embedded
138
+ * flat grid (pivot off) hides deselected columns. Default false.
139
+ */
140
+ columnTree?: boolean
141
+ /** Hidden column ids (field names) when `columnTree` is on. Bindable. */
142
+ hiddenFields?: string[]
143
+ /** Fired when column visibility changes (columnTree mode). */
144
+ onHiddenFieldsChange?: (hidden: string[]) => void
145
+ /**
146
+ * Show a vertical Columns / Filters tab rail on the docked panel
147
+ * (`panelPosition='right'`). The Filters tab is a set-filter
148
+ * builder: "Add Filter" -> pick a column -> tick which values pass. Filters
149
+ * narrow the source rows (they drive `layout.filters`). Default false.
150
+ */
151
+ toolTabs?: boolean
152
+ }
153
+ let {
154
+ data,
155
+ fields,
156
+ layout = $bindable<PivotLayout>(defaultLayoutFor([])),
157
+ onLayoutChange,
158
+ presets,
159
+ aggregators = ALL_AGGREGATORS,
160
+ showToolbar = true,
161
+ showFieldList = true,
162
+ showFiltersWell = true,
163
+ onExport,
164
+ gridHeight = '100%',
165
+ embedGrid = true,
166
+ onPivot,
167
+ expandable = false,
168
+ decorateColumns,
169
+ onCellClick,
170
+ chartable = true,
171
+ defaultView = 'table',
172
+ panelPosition = 'top',
173
+ panelWidth = 280,
174
+ pivotMode = $bindable(true),
175
+ flatColumns,
176
+ onPivotModeChange,
177
+ gridFitColumns = true,
178
+ columnVirtualization = true,
179
+ contextMenu = true,
180
+ columnTree = false,
181
+ hiddenFields = $bindable<string[]>([]),
182
+ onHiddenFieldsChange,
183
+ toolTabs = false,
184
+ }: Props = $props()
185
+
186
+ // ---- Columns / Filters tab rail ----------------------------------
187
+ let activeTab = $state<'columns' | 'filters'>('columns')
188
+ let filterCollapsed = $state<Set<string>>(new Set())
189
+ let filterSearch = $state<Record<string, string>>({})
190
+ let addFilterOpen = $state(false)
191
+ let addFilterSearch = $state('')
192
+ function toggleFilterCard(field: string) {
193
+ const next = new Set(filterCollapsed)
194
+ if (next.has(field)) next.delete(field); else next.add(field)
195
+ filterCollapsed = next
196
+ }
197
+ function toggleFilterValue(f: { field: string; allowed: string[] | null }, v: string, all: string[]) {
198
+ const cur = f.allowed ?? all.slice()
199
+ const next = cur.includes(v) ? cur.filter((x) => x !== v) : [...cur, v]
200
+ setFilterAllowed(f.field, next.length === all.length ? null : next)
201
+ }
202
+ function addFilterField(field: string) {
203
+ if (!layout.filters.some((f) => f.field === field)) {
204
+ emit({ ...layout, filters: [...layout.filters, { field, allowed: null }] })
205
+ }
206
+ const nc = new Set(filterCollapsed); nc.delete(field); filterCollapsed = nc
207
+ addFilterOpen = false
208
+ addFilterSearch = ''
209
+ }
210
+ const availableFilterFields = $derived.by(() => {
211
+ const used = new Set(layout.filters.map((f) => f.field))
212
+ const q = addFilterSearch.trim().toLowerCase()
213
+ return fields.filter((f) => !used.has(f.field) && (!q || f.label.toLowerCase().includes(q)))
214
+ })
215
+ // Close the Add-Filter column picker on outside click.
216
+ $effect(() => {
217
+ if (!addFilterOpen) return
218
+ function on(e: MouseEvent) {
219
+ const t = e.target as HTMLElement | null
220
+ if (t && t.closest(`[data-pvd-addfilter="${uid}"]`)) return
221
+ addFilterOpen = false
222
+ }
223
+ window.addEventListener('mousedown', on)
224
+ return () => window.removeEventListener('mousedown', on)
225
+ })
226
+
227
+ const showPivotToggle = $derived(!!flatColumns)
228
+ function setPivotMode(on: boolean) {
229
+ pivotMode = on
230
+ onPivotModeChange?.(on)
231
+ }
232
+
233
+ // Default the layout once on mount if the consumer passed nothing
234
+ // meaningful. We seed inside $effect.pre so $bindable picks up the
235
+ // mutation BEFORE the first render reads it.
236
+ $effect.pre(() => {
237
+ if (!layout || (!layout.rows.length && !layout.cols.length && !layout.values.length && !layout.filters.length)) {
238
+ layout = defaultLayoutFor(fields)
239
+ }
240
+ })
241
+
242
+ const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
243
+ const uid = `pvd-${Math.random().toString(36).slice(2, 8)}`
244
+
245
+ // Lookup helpers -----------------------------------------------------
246
+ const fieldsByName = $derived(new Map(fields.map((f) => [f.field, f])))
247
+ /** Field is currently "in use" - on any well - so the picker can grey it out. */
248
+ function isFieldInLayout(field: string): boolean {
249
+ return layout.rows.includes(field) ||
250
+ layout.cols.includes(field) ||
251
+ layout.values.some((v) => v.field === field) ||
252
+ layout.filters.some((f) => f.field === field)
253
+ }
254
+
255
+ // ---- Search + grouped picker --------------------------------------
256
+ let search = $state('')
257
+ const filteredFields = $derived(
258
+ fields.filter((f) => !search.trim() || f.label.toLowerCase().includes(search.trim().toLowerCase())),
259
+ )
260
+ const groupedFields = $derived.by(() => {
261
+ const groups = new Map<string, PivotField<T>[]>()
262
+ for (const f of filteredFields) {
263
+ const key = f.group ?? (f.kind === 'dimension' ? 'Dimensions' : 'Measures')
264
+ const arr = groups.get(key) ?? []
265
+ arr.push(f); groups.set(key, arr)
266
+ }
267
+ return [...groups.entries()]
268
+ })
269
+
270
+ // ---- Columns tool panel: visibility tree (columnTree mode) --------
271
+ const hiddenSet = $derived(new Set(hiddenFields))
272
+ const isFieldVisible = (field: string) => !hiddenSet.has(field)
273
+ let collapsedGroups = $state<Set<string>>(new Set())
274
+ function toggleGroupCollapsed(group: string) {
275
+ const next = new Set(collapsedGroups)
276
+ if (next.has(group)) next.delete(group); else next.add(group)
277
+ collapsedGroups = next
278
+ }
279
+ function emitHidden(next: Set<string>) {
280
+ const arr = [...next]
281
+ hiddenFields = arr
282
+ onHiddenFieldsChange?.(arr)
283
+ }
284
+ function setFieldVisible(field: string, visible: boolean) {
285
+ const next = new Set(hiddenSet)
286
+ if (visible) next.delete(field); else next.add(field)
287
+ emitHidden(next)
288
+ }
289
+ /** Every field id (across all groups), regardless of the search filter. */
290
+ const allFieldIds = $derived(fields.map((f) => f.field))
291
+ /** Visibility of a whole group: 'all' | 'some' | 'none'. */
292
+ function groupVisibility(items: PivotField<T>[]): 'all' | 'some' | 'none' {
293
+ const vis = items.filter((f) => isFieldVisible(f.field)).length
294
+ return vis === 0 ? 'none' : vis === items.length ? 'all' : 'some'
295
+ }
296
+ function toggleGroupVisible(items: PivotField<T>[]) {
297
+ const makeVisible = groupVisibility(items) !== 'all'
298
+ const next = new Set(hiddenSet)
299
+ for (const f of items) { if (makeVisible) next.delete(f.field); else next.add(f.field) }
300
+ emitHidden(next)
301
+ }
302
+ const allVisibility = $derived.by<'all' | 'some' | 'none'>(() => {
303
+ const vis = allFieldIds.filter((id) => isFieldVisible(id)).length
304
+ return vis === 0 ? 'none' : vis === allFieldIds.length ? 'all' : 'some'
305
+ })
306
+ function toggleAllVisible() {
307
+ const makeVisible = allVisibility !== 'all'
308
+ emitHidden(makeVisible ? new Set<string>() : new Set(allFieldIds))
309
+ }
310
+ /** `flatColumns` with hidden leaves removed (and now-empty groups dropped). */
311
+ const visibleFlatColumns = $derived.by(() => {
312
+ if (!flatColumns || !columnTree || hiddenSet.size === 0) return flatColumns
313
+ type Col = ColumnDef<typeof features, T>
314
+ const filter = (cols: Col[]): Col[] => {
315
+ const out: Col[] = []
316
+ for (const c of cols) {
317
+ const kids = (c as { columns?: Col[] }).columns
318
+ if (kids?.length) {
319
+ const fk = filter(kids)
320
+ if (fk.length) out.push({ ...c, columns: fk } as Col)
321
+ } else {
322
+ const id = ((c as { field?: string }).field ?? c.id) as string | undefined
323
+ if (!id || !hiddenSet.has(id)) out.push(c)
324
+ }
325
+ }
326
+ return out
327
+ }
328
+ return filter(flatColumns)
329
+ })
330
+
331
+ // ---- Mutation helpers ---------------------------------------------
332
+ function emit(next: PivotLayout) {
333
+ layout = next
334
+ onLayoutChange?.(next)
335
+ }
336
+ function defaultWellFor(field: PivotField<T>): Well {
337
+ return field.kind === 'measure' ? 'values' : 'rows'
338
+ }
339
+ /** Add a field to a well at `index` (or end). For rows / cols / filters a
340
+ * field can appear at most ONCE - dragging it in moves it. For values
341
+ * the same field with the SAME aggregator can only appear once, but
342
+ * the same field with DIFFERENT aggregators is valid (e.g. spend/sum
343
+ * + spend/avg in a scorecard). `sourceIndex` is the chip's position
344
+ * in `values` when dragging within that well, used to dedup the move. */
345
+ function addToWell(field: string, well: Well, index = Infinity, sourceIndex?: number) {
346
+ const f = fieldsByName.get(field)
347
+ if (!f) return
348
+ const next: PivotLayout = {
349
+ ...layout,
350
+ rows: layout.rows.filter((x) => x !== field),
351
+ cols: layout.cols.filter((x) => x !== field),
352
+ // For values: only strip if we're MOVING a specific chip out of the
353
+ // well. Don't strip every chip with the same field (that broke
354
+ // "spend/sum + spend/avg" presets with a duplicate-key error).
355
+ values: sourceIndex !== undefined
356
+ ? layout.values.filter((_, i) => i !== sourceIndex)
357
+ : layout.values.slice(),
358
+ filters: layout.filters.filter((v) => v.field !== field),
359
+ }
360
+ if (well === 'rows' || well === 'cols') {
361
+ const arr = next[well]
362
+ arr.splice(Math.min(arr.length, index), 0, field)
363
+ } else if (well === 'values') {
364
+ const agg = f.defaultAgg ?? 'sum' as PivotAggregatorId
365
+ // Skip if (field, agg) pair already present after the strip.
366
+ if (next.values.some((v) => v.field === field && v.agg === agg)) {
367
+ emit(next); return
368
+ }
369
+ const chip = { field, agg, label: f.label, format: f.format }
370
+ next.values.splice(Math.min(next.values.length, index), 0, chip)
371
+ } else if (well === 'filters') {
372
+ next.filters.splice(Math.min(next.filters.length, index), 0, { field, allowed: null })
373
+ }
374
+ emit(next)
375
+ // A filter added with `allowed: null` passes every row, so dropping a field
376
+ // into Filters changes nothing until you pick values - auto-open the picker
377
+ // so the drop has an immediate, obvious effect.
378
+ if (well === 'filters') openMenu = { kind: 'filter', field }
379
+ }
380
+ function removeFromWell(field: string, well: Well, valueIndex?: number) {
381
+ const next: PivotLayout = { ...layout }
382
+ if (well === 'rows') next.rows = layout.rows.filter((x) => x !== field)
383
+ else if (well === 'cols') next.cols = layout.cols.filter((x) => x !== field)
384
+ else if (well === 'values') {
385
+ // Remove a SPECIFIC chip by its index, since the same field can
386
+ // appear multiple times with different aggregators.
387
+ next.values = valueIndex !== undefined
388
+ ? layout.values.filter((_, i) => i !== valueIndex)
389
+ : layout.values.filter((v) => v.field !== field)
390
+ }
391
+ else if (well === 'filters') next.filters = layout.filters.filter((v) => v.field !== field)
392
+ emit(next)
393
+ }
394
+ function toggleFieldDefault(field: string) {
395
+ const f = fieldsByName.get(field); if (!f) return
396
+ if (isFieldInLayout(field)) {
397
+ // Remove from every well (all chips for this field, even multiple
398
+ // value chips with different aggregators).
399
+ emit({
400
+ ...layout,
401
+ rows: layout.rows.filter((x) => x !== field),
402
+ cols: layout.cols.filter((x) => x !== field),
403
+ values: layout.values.filter((v) => v.field !== field),
404
+ filters: layout.filters.filter((v) => v.field !== field),
405
+ })
406
+ } else {
407
+ addToWell(field, defaultWellFor(f))
408
+ }
409
+ }
410
+ /** Update the aggregator of the chip at the given index. If the change
411
+ * would produce a duplicate (field, agg) pair, the chip is removed
412
+ * instead of creating a key collision. */
413
+ function setAggregatorAt(index: number, agg: PivotAggregatorId) {
414
+ const chip = layout.values[index]
415
+ if (!chip) return
416
+ const field = chip.field
417
+ const dup = layout.values.some((v, i) => i !== index && v.field === field && v.agg === agg)
418
+ const label = AGG_LABEL[agg] + ' of ' + (fieldsByName.get(field)?.label ?? field)
419
+ emit({
420
+ ...layout,
421
+ values: dup
422
+ ? layout.values.filter((_, i) => i !== index)
423
+ : layout.values.map((v, i) => (i === index ? { ...v, agg, label } : v)),
424
+ })
425
+ }
426
+ function setFilterAllowed(field: string, allowed: string[] | null) {
427
+ emit({
428
+ ...layout,
429
+ filters: layout.filters.map((f) => (f.field === field ? { ...f, allowed } : f)),
430
+ })
431
+ }
432
+ function toggleHideSubtotals() {
433
+ emit({ ...layout, hideSubtotals: !layout.hideSubtotals })
434
+ }
435
+ function toggleHideGrandTotals() {
436
+ emit({ ...layout, hideGrandTotals: !layout.hideGrandTotals })
437
+ }
438
+ function reset() {
439
+ emit(defaultLayoutFor(fields))
440
+ }
441
+ function loadPreset(p: PivotPreset) {
442
+ emit(structuredClone(p.layout) as PivotLayout)
443
+ }
444
+
445
+ // ---- Drag-and-drop ------------------------------------------------
446
+ /** What's being dragged: field id + the well it came from. `dragIndex`
447
+ * is the source position in the values well (only set when dragging a
448
+ * value chip; other wells have at most one chip per field so they
449
+ * don't need an index). */
450
+ let dragField = $state<string | null>(null)
451
+ let dragFrom = $state<Well | 'rail' | null>(null)
452
+ let dragIndex = $state<number | undefined>(undefined)
453
+ let dragOver = $state<Well | null>(null)
454
+ function onDragStart(e: DragEvent, field: string, from: Well | 'rail', index?: number) {
455
+ dragField = field
456
+ dragFrom = from
457
+ dragIndex = index
458
+ if (e.dataTransfer) {
459
+ e.dataTransfer.effectAllowed = 'move'
460
+ e.dataTransfer.setData('text/plain', field)
461
+ }
462
+ }
463
+ function onDragOver(e: DragEvent, well: Well) {
464
+ e.preventDefault()
465
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
466
+ dragOver = well
467
+ }
468
+ function onDragLeave() { dragOver = null }
469
+ function onDrop(e: DragEvent, well: Well) {
470
+ e.preventDefault()
471
+ dragOver = null
472
+ const field = dragField || e.dataTransfer?.getData('text/plain') || ''
473
+ if (!field) return
474
+ // Pass the source index only when dragging FROM the values well, so
475
+ // a same-field move (e.g. reorder spend/avg) removes the right chip.
476
+ const srcIdx = dragFrom === 'values' ? dragIndex : undefined
477
+ addToWell(field, well, Infinity, srcIdx)
478
+ dragField = null
479
+ dragFrom = null
480
+ dragIndex = undefined
481
+ }
482
+
483
+ // ---- Apply filters BEFORE pivot ------------------------------------
484
+ // The pivot model itself has no filtering, but the well clearly should:
485
+ // a Filters chip with `allowed` restricts which rows reach the model.
486
+ const filteredData = $derived.by(() => {
487
+ if (!layout.filters.length) return data
488
+ return data.filter((row) => {
489
+ for (const f of layout.filters) {
490
+ if (f.allowed == null) continue
491
+ const v = row[f.field]
492
+ if (!f.allowed.includes(String(v))) return false
493
+ }
494
+ return true
495
+ })
496
+ })
497
+
498
+ // ---- Build the pivot model ----------------------------------------
499
+ // Whenever the layout or data changes, re-run the pivot. Empty layouts
500
+ // are tolerated: the grid will show a friendly empty state.
501
+ const pivot = $derived.by(() => {
502
+ if (!layout.values.length && !layout.rows.length && !layout.cols.length) return null
503
+ if (!layout.values.length) return null
504
+ return createPivotModel(filteredData, {
505
+ rows: layout.rows as Array<keyof T & string>,
506
+ cols: layout.cols as Array<keyof T & string>,
507
+ values: layout.values.map((v) => ({
508
+ field: v.field as keyof T & string,
509
+ agg: v.agg,
510
+ label: v.label,
511
+ format: v.format,
512
+ })),
513
+ grandTotalRow: !layout.hideGrandTotals,
514
+ grandTotalCol: !layout.hideGrandTotals,
515
+ rowSubtotals: !layout.hideSubtotals,
516
+ })
517
+ })
518
+ $effect(() => {
519
+ if (pivot) onPivot?.(pivot.rows, pivot.columns)
520
+ })
521
+
522
+ // ---- Table <-> Chart view (the same layout as a live pivot chart) ----
523
+ let view = $state<'table' | 'chart'>(defaultView === 'chart' && chartable ? 'chart' : 'table')
524
+ let chartType = $state<ChartType>('bar')
525
+ let chartStacked = $state(false)
526
+ const CHART_TYPES: Array<{ value: ChartType; label: string }> = [
527
+ { value: 'bar', label: 'Bar' },
528
+ { value: 'line', label: 'Line' },
529
+ { value: 'area', label: 'Area' },
530
+ { value: 'pie', label: 'Pie' },
531
+ ]
532
+ const chartSpec = $derived.by(() =>
533
+ pivot && chartable && view === 'chart'
534
+ ? pivotToChartSpec(pivot, { type: chartType, stacked: chartStacked })
535
+ : null,
536
+ )
537
+
538
+ // ---- Expand / collapse (when `expandable` is on) ------------------
539
+ // We hold a `collapsed` set of pivot row ids; descendants of a
540
+ // collapsed group are filtered out via the model helper. Reset to
541
+ // fully-expanded whenever the source pivot rebuilds with different
542
+ // group ids - otherwise stale ids would linger across layout changes.
543
+ let collapsed = $state<Set<string>>(new Set())
544
+ let lastPivotKey = $state('')
545
+ $effect(() => {
546
+ if (!pivot) return
547
+ const key = pivot.rows.map((r) => r.__pivotId).join('|')
548
+ if (key !== lastPivotKey) {
549
+ lastPivotKey = key
550
+ // Keep ids still present, drop the rest.
551
+ const present = new Set(pivot.rows.map((r) => r.__pivotId))
552
+ const next = new Set<string>()
553
+ for (const id of collapsed) if (present.has(id)) next.add(id)
554
+ if (next.size !== collapsed.size) collapsed = next
555
+ }
556
+ })
557
+ function toggleRow(id: string) {
558
+ const next = new Set(collapsed)
559
+ if (next.has(id)) next.delete(id); else next.add(id)
560
+ collapsed = next
561
+ }
562
+ function expandAll() { collapsed = new Set() }
563
+ function collapseAll() {
564
+ if (!pivot) return
565
+ const next = new Set<string>()
566
+ for (const r of pivot.rows) if (r.__pivotExpandable) next.add(r.__pivotId)
567
+ collapsed = next
568
+ }
569
+ /** Rows the grid should actually render, honouring the collapsed set. */
570
+ const visibleRows = $derived.by(() => {
571
+ if (!pivot) return [] as PivotRow[]
572
+ if (!expandable || collapsed.size === 0) return pivot.rows
573
+ const expanded = new Set<string>()
574
+ for (const r of pivot.rows) {
575
+ if (r.__pivotExpandable && !collapsed.has(r.__pivotId)) expanded.add(r.__pivotId)
576
+ }
577
+ return filterCollapsedPivotRows(pivot.rows, expanded)
578
+ })
579
+
580
+ // ---- Column tree with optional decoration -------------------------
581
+ // When `expandable`, the first (label) column gets a built-in chevron
582
+ // renderer. The consumer's `decorateColumns` runs LAST so it can
583
+ // override even the chevron behaviour if it wants something custom.
584
+ const finalColumns = $derived.by(() => {
585
+ if (!pivot) return [] as ColumnDef<typeof features, PivotRow>[]
586
+ let cols = pivot.columns
587
+ // The engine builds the row-header column header from raw field names
588
+ // (e.g. "region / country") which renders lowercase. Replace it with
589
+ // the chained `field.label` from the picker so the rendered header
590
+ // matches the chip text in the wells (e.g. "Region / Country").
591
+ if (cols.length && layout.rows.length) {
592
+ const rowLabel = layout.rows
593
+ .map((f) => fieldsByName.get(f)?.label ?? f)
594
+ .join(' / ')
595
+ cols = [{ ...cols[0]!, header: rowLabel }, ...cols.slice(1)]
596
+ }
597
+ if (expandable) {
598
+ cols = cols.map((c, i) => {
599
+ if (i !== 0) return c
600
+ return {
601
+ ...c,
602
+ cell: (ctx) => renderSnippet(ChevronLabelCell, {
603
+ row: ctx.row.original,
604
+ collapsed: collapsed.has(ctx.row.original.__pivotId),
605
+ onToggle: () => toggleRow(ctx.row.original.__pivotId),
606
+ }),
607
+ }
608
+ })
609
+ }
610
+ return decorateColumns ? decorateColumns(cols, layout) : cols
611
+ })
612
+
613
+ // ---- Filter chip menu (which values pass) --------------------------
614
+ /** Distinct values for a field, computed from the FULL data (so the
615
+ * menu shows every option, not just those the current filters
616
+ * already pass). */
617
+ function distinctValuesFor(field: string): string[] {
618
+ const set = new Set<string>()
619
+ for (const row of data) set.add(String(row[field]))
620
+ return [...set].sort((a, b) => a.localeCompare(b))
621
+ }
622
+
623
+ // ---- Active menus (open one chip at a time) ----------------------
624
+ // Filter menus key by `field` (one filter chip per field).
625
+ // Agg menus key by `index` (a field can have multiple value chips at
626
+ // different aggregators; index is the only stable identity).
627
+ let openMenu = $state<
628
+ | { kind: 'filter'; field: string }
629
+ | { kind: 'agg'; index: number }
630
+ | null
631
+ >(null)
632
+ function toggleFilterMenu(field: string) {
633
+ if (openMenu?.kind === 'filter' && openMenu.field === field) openMenu = null
634
+ else openMenu = { kind: 'filter', field }
635
+ }
636
+ function toggleAggMenu(index: number) {
637
+ if (openMenu?.kind === 'agg' && openMenu.index === index) openMenu = null
638
+ else openMenu = { kind: 'agg', index }
639
+ }
640
+ function closeMenu() { openMenu = null }
641
+ // Close on outside click.
642
+ $effect(() => {
643
+ if (!openMenu) return
644
+ function on(e: MouseEvent) {
645
+ const t = e.target as HTMLElement | null
646
+ if (t && t.closest(`[data-pvd-menu="${uid}"]`)) return
647
+ openMenu = null
648
+ }
649
+ window.addEventListener('mousedown', on)
650
+ return () => window.removeEventListener('mousedown', on)
651
+ })
652
+
653
+ // ---- Presets menu ------------------------------------------------
654
+ let presetsOpen = $state(false)
655
+ function togglePresets() { presetsOpen = !presetsOpen }
656
+ $effect(() => {
657
+ if (!presetsOpen) return
658
+ function on(e: MouseEvent) {
659
+ const t = e.target as HTMLElement | null
660
+ if (t && t.closest(`[data-pvd-presets="${uid}"]`)) return
661
+ presetsOpen = false
662
+ }
663
+ window.addEventListener('mousedown', on)
664
+ return () => window.removeEventListener('mousedown', on)
665
+ })
666
+
667
+ // ---- Right-click context menu ------------------------------------
668
+ // A single portalled SvMenuList whose items are computed from whatever
669
+ // was right-clicked: a field-list row, a well chip, or the pivot grid.
670
+ const WELL_LABEL: Record<Well, string> = { rows: 'Rows', cols: 'Columns', values: 'Values', filters: 'Filters' }
671
+ const KIND_WELLS: Record<'dimension' | 'measure', Well[]> = {
672
+ dimension: ['rows', 'cols', 'filters'],
673
+ measure: ['values', 'filters'],
674
+ }
675
+ let ctx = $state<{ x: number; y: number; items: MenuItem[] } | null>(null)
676
+ let ctxPanelEl = $state<HTMLDivElement | null>(null)
677
+ function openCtx(e: MouseEvent, items: MenuItem[]) {
678
+ if (!contextMenu || items.length === 0) return
679
+ e.preventDefault()
680
+ e.stopPropagation()
681
+ const W = 210
682
+ const estH = Math.min(items.length, 12) * 32 + 10
683
+ const x = Math.min(e.clientX, window.innerWidth - W - 6)
684
+ const y = Math.min(e.clientY, window.innerHeight - estH - 6)
685
+ ctx = { x: Math.max(6, x), y: Math.max(6, y), items }
686
+ }
687
+ function closeCtx() { ctx = null }
688
+ $effect(() => {
689
+ if (!ctx || !ctxPanelEl) return
690
+ const trap = createFocusTrap(ctxPanelEl, {
691
+ initialFocus: () => ctxPanelEl?.querySelector<HTMLElement>('[role="menuitem"]:not([disabled])') ?? null,
692
+ })
693
+ trap.activate()
694
+ const layer = createDismissableLayer({ element: () => ctxPanelEl, onDismiss: closeCtx })
695
+ layer.activate()
696
+ const offScroll = onScrollOutside(() => ctxPanelEl, closeCtx)
697
+ return () => { layer.release(); trap.release(); offScroll() }
698
+ })
699
+
700
+ function clearWell(well: Well) {
701
+ if (well === 'rows') emit({ ...layout, rows: [] })
702
+ else if (well === 'cols') emit({ ...layout, cols: [] })
703
+ else if (well === 'values') emit({ ...layout, values: [] })
704
+ else emit({ ...layout, filters: [] })
705
+ }
706
+
707
+ /** Menu for a field-list (rail) row. */
708
+ function fieldMenu(field: string): MenuItem[] {
709
+ const f = fieldsByName.get(field)
710
+ if (!f) return []
711
+ const items: MenuItem[] = KIND_WELLS[f.kind].map((w) => ({
712
+ label: `Add to ${WELL_LABEL[w]}`,
713
+ icon: icPlus,
714
+ onSelect: () => addToWell(field, w),
715
+ }))
716
+ if (isFieldInLayout(field)) {
717
+ items.push({ separator: true }, { label: 'Remove from pivot', icon: icTrash, onSelect: () => toggleFieldDefault(field) })
718
+ }
719
+ return items
720
+ }
721
+
722
+ /** Menu for a chip already sitting in a well. */
723
+ function chipMenu(well: Well, field: string, valueIndex?: number): MenuItem[] {
724
+ const f = fieldsByName.get(field)
725
+ const items: MenuItem[] = []
726
+ if (well === 'values' && valueIndex !== undefined) {
727
+ const current = layout.values[valueIndex]?.agg
728
+ items.push({
729
+ label: 'Aggregate',
730
+ icon: icSigma,
731
+ children: aggregators.map((agg) => ({
732
+ label: AGG_LABEL[agg],
733
+ shortcut: agg === current ? '✓' : undefined,
734
+ onSelect: () => setAggregatorAt(valueIndex, agg),
735
+ })),
736
+ })
737
+ }
738
+ if (f) {
739
+ const targets = KIND_WELLS[f.kind].filter((w) => w !== well)
740
+ if (targets.length) {
741
+ items.push({
742
+ label: 'Move to',
743
+ icon: icMove,
744
+ children: targets.map((w) => ({
745
+ label: WELL_LABEL[w],
746
+ onSelect: () => addToWell(field, w, Infinity, well === 'values' ? valueIndex : undefined),
747
+ })),
748
+ })
749
+ }
750
+ }
751
+ if (well === 'filters') items.push({ label: 'Edit filter…', icon: icFilter, onSelect: () => toggleFilterMenu(field) })
752
+ items.push(
753
+ { separator: true },
754
+ { label: 'Remove', icon: icTrash, onSelect: () => removeFromWell(field, well, valueIndex) },
755
+ { label: `Clear ${WELL_LABEL[well]}`, icon: icClear, onSelect: () => clearWell(well) },
756
+ )
757
+ return items
758
+ }
759
+
760
+ /** Menu for an empty area of a well. */
761
+ function wellMenu(well: Well): MenuItem[] {
762
+ const count = well === 'values' ? layout.values.length : layout[well].length
763
+ return [{ label: `Clear ${WELL_LABEL[well]}`, icon: icClear, disabled: count === 0, onSelect: () => clearWell(well) }]
764
+ }
765
+
766
+ /** Menu for the pivot grid area (expand / collapse when expandable). */
767
+ function gridMenu(): MenuItem[] {
768
+ const items: MenuItem[] = []
769
+ if (expandable && pivot) {
770
+ items.push(
771
+ { label: 'Expand all', icon: icExpand, onSelect: expandAll },
772
+ { label: 'Collapse all', icon: icCollapse, onSelect: collapseAll },
773
+ )
774
+ }
775
+ if (onExport && pivot) {
776
+ if (items.length) items.push({ separator: true })
777
+ items.push({ label: 'Export…', icon: icExport, onSelect: () => onExport!(layout, pivot!.rows) })
778
+ }
779
+ return items
780
+ }
781
+ </script>
782
+
783
+ <section class="pvd" data-uid={uid}>
784
+ {#if showToolbar}
785
+ <header class="pvd-toolbar">
786
+ <strong class="pvd-title">Pivot designer</strong>
787
+ <button type="button" class="pvd-btn" onclick={reset} title="Restore the default layout">{@render ic('reset')} Reset</button>
788
+ {#if presets?.length}
789
+ <div class="pvd-presets" data-pvd-presets={uid}>
790
+ <button type="button" class="pvd-btn" onclick={togglePresets}>{@render ic('presets')} Presets {@render ic('chevron-down')}</button>
791
+ {#if presetsOpen}
792
+ <div class="pvd-popover">
793
+ {#each presets as p (p.name)}
794
+ <button type="button" class="pvd-popover-item" onclick={() => { loadPreset(p); presetsOpen = false }}>{p.name}</button>
795
+ {/each}
796
+ </div>
797
+ {/if}
798
+ </div>
799
+ {/if}
800
+ <label class="pvd-toggle">
801
+ <input type="checkbox" checked={!layout.hideSubtotals} onchange={toggleHideSubtotals} /> Subtotals
802
+ </label>
803
+ <label class="pvd-toggle">
804
+ <input type="checkbox" checked={!layout.hideGrandTotals} onchange={toggleHideGrandTotals} /> Grand totals
805
+ </label>
806
+ {#if chartable}
807
+ <div class="pvd-viewswitch" role="group" aria-label="View">
808
+ <button type="button" class="pvd-view-btn" class:is-active={view === 'table'} onclick={() => (view = 'table')}>{@render ic('table')} Table</button>
809
+ <button type="button" class="pvd-view-btn" class:is-active={view === 'chart'} onclick={() => (view = 'chart')}>{@render ic('chart')} Chart</button>
810
+ </div>
811
+ {#if view === 'chart'}
812
+ <label class="pvd-toggle">
813
+ <select class="pvd-select" value={chartType} onchange={(e) => (chartType = e.currentTarget.value as ChartType)}>
814
+ {#each CHART_TYPES as t (t.value)}<option value={t.value}>{t.label}</option>{/each}
815
+ </select>
816
+ </label>
817
+ {#if chartType !== 'pie'}
818
+ <label class="pvd-toggle">
819
+ <input type="checkbox" checked={chartStacked} onchange={(e) => (chartStacked = e.currentTarget.checked)} /> Stacked
820
+ </label>
821
+ {/if}
822
+ {/if}
823
+ {/if}
824
+ <div class="pvd-spacer"></div>
825
+ {#if onExport && pivot}
826
+ <button type="button" class="pvd-btn pvd-btn-primary" onclick={() => onExport!(layout, pivot!.rows)}>{@render ic('export')} Export…</button>
827
+ {/if}
828
+ </header>
829
+ {/if}
830
+
831
+ <div class="pvd-body" data-panel={panelPosition} class:no-rail={!showFieldList && panelPosition === 'top'}>
832
+ {#if panelPosition === 'right'}
833
+ <div class="pvd-gridwrap">{@render gridBlock()}</div>
834
+ <aside class="pvd-panel" class:pvd-panel-tabbed={toolTabs} style={`width:${typeof panelWidth === 'number' ? panelWidth + 'px' : panelWidth}`} aria-label="Pivot panel">
835
+ {#if toolTabs}
836
+ <div class="pvd-tabbody">
837
+ {#if activeTab === 'columns'}{@render columnsPanel()}{:else}{@render filtersPanel()}{/if}
838
+ </div>
839
+ <div class="pvd-tabrail" role="tablist" aria-label="Tool panel tabs">
840
+ <button type="button" class="pvd-tabbtn" role="tab" aria-selected={activeTab === 'columns'} class:is-active={activeTab === 'columns'} onclick={() => (activeTab = 'columns')}>
841
+ {@render ic('columns')}<span>Columns</span>
842
+ </button>
843
+ <button type="button" class="pvd-tabbtn" role="tab" aria-selected={activeTab === 'filters'} class:is-active={activeTab === 'filters'} onclick={() => (activeTab = 'filters')}>
844
+ {@render ic('filter')}<span>Filters</span>
845
+ </button>
846
+ </div>
847
+ {:else}
848
+ {@render columnsPanel()}
849
+ {/if}
850
+ </aside>
851
+ {:else}
852
+ {#if showFieldList}
853
+ <aside class="pvd-rail" aria-label="Available fields">{@render railBlock()}</aside>
854
+ {/if}
855
+ <div class="pvd-main">
856
+ {#if showPivotToggle}<div class="pvd-topbar">{@render pivotToggle()}</div>{/if}
857
+ <div class="pvd-wells" class:two={!showFiltersWell}>{@render wellsBlock()}</div>
858
+ {@render gridBlock()}
859
+ </div>
860
+ {/if}
861
+ </div>
862
+ </section>
863
+
864
+ {#if ctx}
865
+ <div
866
+ bind:this={ctxPanelEl}
867
+ class="pvd-ctx"
868
+ use:portalToBody
869
+ use:popIn={{}}
870
+ style:position="fixed"
871
+ style:top={`${ctx.y}px`}
872
+ style:left={`${ctx.x}px`}
873
+ role="presentation"
874
+ >
875
+ <SvMenuList items={ctx.items} onclose={closeCtx} onselect={() => {}} />
876
+ </div>
877
+ {/if}
878
+
879
+ <!-- ============================ Icons ============================ -->
880
+ {#snippet ic(name: string)}
881
+ <svg class="pvd-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
882
+ {#if name === 'filter'}
883
+ <path d="M3 5h18l-7 8v6l-4 2v-8z" />
884
+ {:else if name === 'columns'}
885
+ <rect x="3" y="4" width="18" height="16" rx="1.5" />
886
+ <path d="M9 4v16" /><path d="M15 4v16" />
887
+ {:else if name === 'rows'}
888
+ <rect x="3" y="4" width="18" height="16" rx="1.5" />
889
+ <path d="M3 10h18" /><path d="M3 15h18" />
890
+ {:else if name === 'sigma'}
891
+ <path d="M17 5H7l6 7-6 7h10" />
892
+ {:else if name === 'dimension'}
893
+ <path d="M7 7h.01" />
894
+ <path d="M11 3h4.6a2 2 0 0 1 1.4.6l4 4a2 2 0 0 1 0 2.8l-6.6 6.6a2 2 0 0 1-2.8 0l-4-4a2 2 0 0 1-.6-1.4V6a3 3 0 0 1 3-3z" />
895
+ {:else if name === 'reset'}
896
+ <path d="M3 12a9 9 0 1 0 3-6.7L3 8" /><path d="M3 3v5h5" />
897
+ {:else if name === 'presets'}
898
+ <path d="M12 3l8 4.5-8 4.5-8-4.5z" /><path d="M4 12l8 4.5 8-4.5" /><path d="M4 16.5l8 4.5 8-4.5" />
899
+ {:else if name === 'export'}
900
+ <path d="M12 3v12" /><path d="M8 11l4 4 4-4" /><path d="M4 19h16" />
901
+ {:else if name === 'table'}
902
+ <rect x="3" y="4" width="18" height="16" rx="1.5" />
903
+ <path d="M3 9h18" /><path d="M9 9v11" />
904
+ {:else if name === 'chart'}
905
+ <path d="M4 20V4" /><path d="M4 20h16" />
906
+ <rect x="7" y="12" width="3" height="5" /><rect x="12" y="8" width="3" height="9" /><rect x="17" y="5" width="3" height="12" />
907
+ {:else if name === 'chevron-down'}
908
+ <path d="M6 9l6 6 6-6" />
909
+ {:else if name === 'grip'}
910
+ <circle cx="9" cy="6" r="1.3" fill="currentColor" stroke="none" /><circle cx="15" cy="6" r="1.3" fill="currentColor" stroke="none" />
911
+ <circle cx="9" cy="12" r="1.3" fill="currentColor" stroke="none" /><circle cx="15" cy="12" r="1.3" fill="currentColor" stroke="none" />
912
+ <circle cx="9" cy="18" r="1.3" fill="currentColor" stroke="none" /><circle cx="15" cy="18" r="1.3" fill="currentColor" stroke="none" />
913
+ {:else if name === 'plus'}
914
+ <path d="M12 5v14" /><path d="M5 12h14" />
915
+ {:else if name === 'move'}
916
+ <path d="M5 9l-3 3 3 3" /><path d="M9 5l3-3 3 3" /><path d="M15 19l-3 3-3-3" /><path d="M19 9l3 3-3 3" /><path d="M2 12h20" /><path d="M12 2v20" />
917
+ {:else if name === 'trash'}
918
+ <path d="M4 7h16" /><path d="M9 7V5h6v2" /><path d="M6 7l1 13h10l1-13" />
919
+ {:else if name === 'clear'}
920
+ <path d="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6" /><path d="M14 11v6" />
921
+ {:else if name === 'expand'}
922
+ <path d="M8 3H5a2 2 0 0 0-2 2v3" /><path d="M16 3h3a2 2 0 0 1 2 2v3" /><path d="M8 21H5a2 2 0 0 1-2-2v-3" /><path d="M16 21h3a2 2 0 0 0 2-2v-3" />
923
+ {:else if name === 'collapse'}
924
+ <path d="M3 8h3a2 2 0 0 0 2-2V3" /><path d="M21 8h-3a2 2 0 0 1-2-2V3" /><path d="M3 16h3a2 2 0 0 1 2 2v3" /><path d="M21 16h-3a2 2 0 0 0-2 2v3" />
925
+ {:else if name === 'pivot'}
926
+ <rect x="3" y="3" width="18" height="18" rx="1.5" /><path d="M3 9h18" /><path d="M9 9v12" />
927
+ {:else if name === 'check'}
928
+ <path d="M5 13l4 4 10-11" />
929
+ {:else if name === 'addfilter'}
930
+ <path d="M3 6h13" /><path d="M3 12h9" /><path d="M3 18h6" />
931
+ <path d="M17 13v8" /><path d="M13 17h8" />
932
+ {:else if name === 'search'}
933
+ <circle cx="11" cy="11" r="6" /><path d="M20 20l-4.5-4.5" />
934
+ {/if}
935
+ </svg>
936
+ {/snippet}
937
+
938
+ <!-- Zero-arg wrappers so icons can be attached to context-menu items. -->
939
+ {#snippet icPlus()}{@render ic('plus')}{/snippet}
940
+ {#snippet icTrash()}{@render ic('trash')}{/snippet}
941
+ {#snippet icClear()}{@render ic('clear')}{/snippet}
942
+ {#snippet icMove()}{@render ic('move')}{/snippet}
943
+ {#snippet icSigma()}{@render ic('sigma')}{/snippet}
944
+ {#snippet icFilter()}{@render ic('filter')}{/snippet}
945
+ {#snippet icExpand()}{@render ic('expand')}{/snippet}
946
+ {#snippet icCollapse()}{@render ic('collapse')}{/snippet}
947
+ {#snippet icExport()}{@render ic('export')}{/snippet}
948
+
949
+ <!-- ============================ Sub-blocks ============================ -->
950
+ {#snippet pivotToggle()}
951
+ <label class="pvd-pivot-toggle">
952
+ <input type="checkbox" checked={pivotMode} onchange={(e) => setPivotMode(e.currentTarget.checked)} />
953
+ <span class="pvd-switch" class:on={pivotMode}><span class="pvd-switch-knob"></span></span>
954
+ <span class="pvd-pivot-icon">{@render ic('pivot')}</span>
955
+ <span class="pvd-pivot-label">Pivot Mode</span>
956
+ </label>
957
+ {/snippet}
958
+
959
+ {#snippet railBlock()}
960
+ {#if columnTree}
961
+ <!-- AG-style Columns tool panel: select-all + search, then a collapsible
962
+ column-group TREE whose checkboxes toggle column visibility. Fields
963
+ still drag into the wells. -->
964
+ <div class="pvd-tree-top">
965
+ <input
966
+ type="checkbox"
967
+ class="pvd-tree-check"
968
+ checked={allVisibility === 'all'}
969
+ indeterminate={allVisibility === 'some'}
970
+ onchange={toggleAllVisible}
971
+ aria-label="Toggle all columns"
972
+ />
973
+ <input type="search" class="pvd-search pvd-tree-search" placeholder="Search…" bind:value={search} />
974
+ </div>
975
+ <div class="pvd-fieldlist pvd-tree">
976
+ {#each groupedFields as [groupName, items] (groupName)}
977
+ {@const gv = groupVisibility(items)}
978
+ {@const open = !collapsedGroups.has(groupName)}
979
+ <div class="pvd-tree-group">
980
+ <button type="button" class="pvd-tree-twisty" class:is-open={open} onclick={() => toggleGroupCollapsed(groupName)} aria-label={open ? `Collapse ${groupName}` : `Expand ${groupName}`}>
981
+ {@render ic('chevron-down')}
982
+ </button>
983
+ <input type="checkbox" class="pvd-tree-check" checked={gv === 'all'} indeterminate={gv === 'some'} onchange={() => toggleGroupVisible(items)} aria-label={`Toggle ${groupName}`} />
984
+ <span class="pvd-grip" aria-hidden="true">{@render ic('grip')}</span>
985
+ <span class="pvd-tree-group-name">{groupName}</span>
986
+ </div>
987
+ {#if open}
988
+ {#each items as f (f.field)}
989
+ <div
990
+ class="pvd-tree-leaf"
991
+ draggable="true"
992
+ ondragstart={(e) => onDragStart(e, f.field, 'rail')}
993
+ oncontextmenu={(e) => openCtx(e, fieldMenu(f.field))}
994
+ role="button"
995
+ tabindex="0"
996
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setFieldVisible(f.field, !isFieldVisible(f.field)) } }}
997
+ >
998
+ <input type="checkbox" class="pvd-tree-check" checked={isFieldVisible(f.field)} onchange={(e) => setFieldVisible(f.field, e.currentTarget.checked)} aria-label={`Toggle ${f.label}`} />
999
+ <span class="pvd-grip" aria-hidden="true">{@render ic('grip')}</span>
1000
+ <span class="pvd-field-kind" class:is-measure={f.kind === 'measure'} title={f.kind === 'dimension' ? 'Dimension' : 'Measure'}>
1001
+ {@render ic(f.kind === 'dimension' ? 'dimension' : 'sigma')}
1002
+ </span>
1003
+ <span class="pvd-field-label">{f.label}</span>
1004
+ </div>
1005
+ {/each}
1006
+ {/if}
1007
+ {/each}
1008
+ {#if !filteredFields.length}
1009
+ <div class="pvd-empty">No columns match "{search}"</div>
1010
+ {/if}
1011
+ </div>
1012
+ {:else}
1013
+ <input
1014
+ type="search"
1015
+ class="pvd-search"
1016
+ placeholder="Search fields…"
1017
+ bind:value={search}
1018
+ />
1019
+ <div class="pvd-fieldlist">
1020
+ {#each groupedFields as [groupName, items] (groupName)}
1021
+ <div class="pvd-group-head">{groupName}</div>
1022
+ {#each items as f (f.field)}
1023
+ {@const inUse = isFieldInLayout(f.field)}
1024
+ <div
1025
+ class="pvd-field"
1026
+ class:in-use={inUse}
1027
+ draggable="true"
1028
+ ondragstart={(e) => onDragStart(e, f.field, 'rail')}
1029
+ oncontextmenu={(e) => openCtx(e, fieldMenu(f.field))}
1030
+ role="button"
1031
+ tabindex="0"
1032
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleFieldDefault(f.field) } }}
1033
+ >
1034
+ <input type="checkbox" checked={inUse} onchange={() => toggleFieldDefault(f.field)} aria-label={`Toggle ${f.label}`} />
1035
+ <span class="pvd-field-kind" class:is-measure={f.kind === 'measure'} title={f.kind === 'dimension' ? 'Dimension' : 'Measure'}>
1036
+ {@render ic(f.kind === 'dimension' ? 'dimension' : 'sigma')}
1037
+ </span>
1038
+ <span class="pvd-field-label">{f.label}</span>
1039
+ </div>
1040
+ {/each}
1041
+ {/each}
1042
+ {#if !filteredFields.length}
1043
+ <div class="pvd-empty">No fields match "{search}"</div>
1044
+ {/if}
1045
+ </div>
1046
+ {/if}
1047
+ {/snippet}
1048
+
1049
+ {#snippet wellsBlock()}
1050
+ <!-- Filters -->
1051
+ {#if showFiltersWell}
1052
+ <div class="pvd-well"
1053
+ class:drag-over={dragOver === 'filters'}
1054
+ ondragover={(e) => onDragOver(e, 'filters')}
1055
+ ondragleave={onDragLeave}
1056
+ ondrop={(e) => onDrop(e, 'filters')}
1057
+ oncontextmenu={(e) => openCtx(e, wellMenu('filters'))}>
1058
+ <div class="pvd-well-head">{@render ic('filter')} Filters</div>
1059
+ <div class="pvd-well-body">
1060
+ {#each layout.filters as f (f.field)}
1061
+ {@const fd = fieldsByName.get(f.field)}
1062
+ <div class="pvd-chip" draggable="true" ondragstart={(e) => onDragStart(e, f.field, 'filters')} oncontextmenu={(e) => openCtx(e, chipMenu('filters', f.field))}>
1063
+ <span class="pvd-chip-grip" aria-hidden="true">{@render ic('grip')}</span>
1064
+ <button type="button" class="pvd-chip-label" title="Choose which values pass" onclick={() => toggleFilterMenu(f.field)}>
1065
+ {fd?.label ?? f.field}{f.allowed ? `: ${f.allowed.length}` : ''} <span class="pvd-chip-caret">▾</span>
1066
+ </button>
1067
+ <button type="button" class="pvd-chip-x" onclick={() => removeFromWell(f.field, 'filters')} aria-label="Remove">×</button>
1068
+ {#if openMenu?.kind === 'filter' && openMenu?.field === f.field}
1069
+ {@const all = distinctValuesFor(f.field)}
1070
+ <div class="pvd-popover pvd-popover-filter" data-pvd-menu={uid}>
1071
+ <div class="pvd-popover-head">
1072
+ <button type="button" class="pvd-popover-mini" onclick={() => setFilterAllowed(f.field, null)}>All</button>
1073
+ <button type="button" class="pvd-popover-mini" onclick={() => setFilterAllowed(f.field, [])}>None</button>
1074
+ </div>
1075
+ <div class="pvd-popover-list">
1076
+ {#each all as v (v)}
1077
+ {@const checked = f.allowed == null || f.allowed.includes(v)}
1078
+ <label class="pvd-popover-item pvd-popover-check">
1079
+ <input type="checkbox" checked={checked}
1080
+ onchange={() => {
1081
+ const cur = f.allowed ?? all.slice()
1082
+ const next = cur.includes(v) ? cur.filter((x) => x !== v) : [...cur, v]
1083
+ setFilterAllowed(f.field, next.length === all.length ? null : next)
1084
+ }} />
1085
+ <span>{v}</span>
1086
+ </label>
1087
+ {/each}
1088
+ </div>
1089
+ </div>
1090
+ {/if}
1091
+ </div>
1092
+ {/each}
1093
+ {#if !layout.filters.length}<span class="pvd-well-hint">drop fields here to filter the source rows</span>{/if}
1094
+ </div>
1095
+ </div>
1096
+ {/if}
1097
+
1098
+ <!-- Columns -->
1099
+ <div class="pvd-well"
1100
+ class:drag-over={dragOver === 'cols'}
1101
+ ondragover={(e) => onDragOver(e, 'cols')}
1102
+ ondragleave={onDragLeave}
1103
+ ondrop={(e) => onDrop(e, 'cols')}
1104
+ oncontextmenu={(e) => openCtx(e, wellMenu('cols'))}>
1105
+ <div class="pvd-well-head">{@render ic('columns')} Columns</div>
1106
+ <div class="pvd-well-body">
1107
+ {#each layout.cols as field (field)}
1108
+ {@const fd = fieldsByName.get(field)}
1109
+ <div class="pvd-chip" draggable="true" ondragstart={(e) => onDragStart(e, field, 'cols')} oncontextmenu={(e) => openCtx(e, chipMenu('cols', field))}>
1110
+ <span class="pvd-chip-grip" aria-hidden="true">{@render ic('grip')}</span>
1111
+ <span class="pvd-chip-label">{fd?.label ?? field}</span>
1112
+ <button type="button" class="pvd-chip-x" onclick={() => removeFromWell(field, 'cols')} aria-label="Remove">×</button>
1113
+ </div>
1114
+ {/each}
1115
+ {#if !layout.cols.length}<span class="pvd-well-hint">drop dimensions to pivot along the column axis</span>{/if}
1116
+ </div>
1117
+ </div>
1118
+
1119
+ <!-- Rows -->
1120
+ <div class="pvd-well"
1121
+ class:drag-over={dragOver === 'rows'}
1122
+ ondragover={(e) => onDragOver(e, 'rows')}
1123
+ ondragleave={onDragLeave}
1124
+ ondrop={(e) => onDrop(e, 'rows')}
1125
+ oncontextmenu={(e) => openCtx(e, wellMenu('rows'))}>
1126
+ <div class="pvd-well-head">{@render ic('rows')} Rows</div>
1127
+ <div class="pvd-well-body">
1128
+ {#each layout.rows as field (field)}
1129
+ {@const fd = fieldsByName.get(field)}
1130
+ <div class="pvd-chip" draggable="true" ondragstart={(e) => onDragStart(e, field, 'rows')} oncontextmenu={(e) => openCtx(e, chipMenu('rows', field))}>
1131
+ <span class="pvd-chip-grip" aria-hidden="true">{@render ic('grip')}</span>
1132
+ <span class="pvd-chip-label">{fd?.label ?? field}</span>
1133
+ <button type="button" class="pvd-chip-x" onclick={() => removeFromWell(field, 'rows')} aria-label="Remove">×</button>
1134
+ </div>
1135
+ {/each}
1136
+ {#if !layout.rows.length}<span class="pvd-well-hint">drop dimensions to group rows</span>{/if}
1137
+ </div>
1138
+ </div>
1139
+
1140
+ <!-- Values -->
1141
+ <div class="pvd-well"
1142
+ class:drag-over={dragOver === 'values'}
1143
+ ondragover={(e) => onDragOver(e, 'values')}
1144
+ ondragleave={onDragLeave}
1145
+ ondrop={(e) => onDrop(e, 'values')}
1146
+ oncontextmenu={(e) => openCtx(e, wellMenu('values'))}>
1147
+ <div class="pvd-well-head">{@render ic('sigma')} Values</div>
1148
+ <div class="pvd-well-body">
1149
+ {#each layout.values as v, vi (v.field + '|' + v.agg + '|' + vi)}
1150
+ {@const fd = fieldsByName.get(v.field)}
1151
+ <div class="pvd-chip pvd-chip-value" draggable="true" ondragstart={(e) => onDragStart(e, v.field, 'values', vi)} oncontextmenu={(e) => openCtx(e, chipMenu('values', v.field, vi))}>
1152
+ <span class="pvd-chip-grip" aria-hidden="true">{@render ic('grip')}</span>
1153
+ <button type="button" class="pvd-chip-label" onclick={() => toggleAggMenu(vi)}>
1154
+ <span class="pvd-chip-agg">{AGG_LABEL[v.agg]}</span>
1155
+ <span class="pvd-chip-sep">·</span>
1156
+ <span>{fd?.label ?? v.field}</span>
1157
+ </button>
1158
+ <button type="button" class="pvd-chip-x" onclick={() => removeFromWell(v.field, 'values', vi)} aria-label="Remove">×</button>
1159
+ {#if openMenu?.kind === 'agg' && openMenu.index === vi}
1160
+ <div class="pvd-popover" data-pvd-menu={uid}>
1161
+ {#each aggregators as agg (agg)}
1162
+ <button
1163
+ type="button"
1164
+ class="pvd-popover-item"
1165
+ class:is-active={v.agg === agg}
1166
+ onclick={() => { setAggregatorAt(vi, agg); closeMenu() }}
1167
+ >
1168
+ {AGG_LABEL[agg]}
1169
+ </button>
1170
+ {/each}
1171
+ </div>
1172
+ {/if}
1173
+ </div>
1174
+ {/each}
1175
+ {#if !layout.values.length}<span class="pvd-well-hint">drop measures to aggregate</span>{/if}
1176
+ </div>
1177
+ </div>
1178
+ {/snippet}
1179
+
1180
+ <!-- The Columns tab body: pivot toggle + field picker + the wells. -->
1181
+ {#snippet columnsPanel()}
1182
+ {#if showPivotToggle}{@render pivotToggle()}{/if}
1183
+ {#if showFieldList}<div class="pvd-panel-rail">{@render railBlock()}</div>{/if}
1184
+ <div class="pvd-wells pvd-wells--vertical" class:two={!showFiltersWell}>{@render wellsBlock()}</div>
1185
+ {/snippet}
1186
+
1187
+ <!-- The Filters tab body: AG-style set filters. Each active column filter is a
1188
+ collapsible card with (Select All) + a searchable value checklist; the
1189
+ "Add Filter" button opens a column picker to add another. Backed by the
1190
+ same `layout.filters` the pivot uses, so filters narrow the source rows. -->
1191
+ {#snippet filtersPanel()}
1192
+ <div class="pvd-filters-tab">
1193
+ {#each layout.filters as f (f.field)}
1194
+ {@const fd = fieldsByName.get(f.field)}
1195
+ {@const open = !filterCollapsed.has(f.field)}
1196
+ {@const all = distinctValuesFor(f.field)}
1197
+ {@const q = (filterSearch[f.field] ?? '').trim().toLowerCase()}
1198
+ {@const shown = q ? all.filter((v) => v.toLowerCase().includes(q)) : all}
1199
+ <div class="pvd-filt-card">
1200
+ <div class="pvd-filt-head">
1201
+ <button type="button" class="pvd-filt-title" onclick={() => toggleFilterCard(f.field)}>
1202
+ <span class="pvd-filt-twisty" class:is-open={open}>{@render ic('chevron-down')}</span>
1203
+ {fd?.label ?? f.field}{f.allowed ? ` (${f.allowed.length})` : ''}
1204
+ </button>
1205
+ <button type="button" class="pvd-filt-x" onclick={() => removeFromWell(f.field, 'filters')} aria-label={`Remove ${fd?.label ?? f.field} filter`}>×</button>
1206
+ </div>
1207
+ {#if open}
1208
+ <input class="pvd-filt-search" type="search" placeholder="Search…" value={filterSearch[f.field] ?? ''} oninput={(e) => (filterSearch = { ...filterSearch, [f.field]: e.currentTarget.value })} />
1209
+ <label class="pvd-filt-opt pvd-filt-all">
1210
+ <input type="checkbox" checked={f.allowed == null} indeterminate={!!f.allowed && f.allowed.length > 0} onchange={() => setFilterAllowed(f.field, f.allowed == null ? [] : null)} />
1211
+ <span>(Select All)</span>
1212
+ </label>
1213
+ <div class="pvd-filt-list">
1214
+ {#each shown as v (v)}
1215
+ <label class="pvd-filt-opt">
1216
+ <input type="checkbox" checked={f.allowed == null || f.allowed.includes(v)} onchange={() => toggleFilterValue(f, v, all)} />
1217
+ <span>{v}</span>
1218
+ </label>
1219
+ {/each}
1220
+ {#if !shown.length}<div class="pvd-empty">No values match "{filterSearch[f.field]}"</div>{/if}
1221
+ </div>
1222
+ {/if}
1223
+ </div>
1224
+ {/each}
1225
+
1226
+ <div class="pvd-addfilter" data-pvd-addfilter={uid}>
1227
+ <button type="button" class="pvd-addfilter-btn" onclick={() => (addFilterOpen = !addFilterOpen)}>
1228
+ {@render ic('addfilter')} Add Filter
1229
+ </button>
1230
+ {#if addFilterOpen}
1231
+ <div class="pvd-addfilter-menu">
1232
+ <div class="pvd-addfilter-searchwrap">
1233
+ <span class="pvd-addfilter-searchicon">{@render ic('search')}</span>
1234
+ <input class="pvd-addfilter-search" type="search" placeholder="Search columns…" bind:value={addFilterSearch} />
1235
+ </div>
1236
+ <div class="pvd-addfilter-list">
1237
+ {#each availableFilterFields as f (f.field)}
1238
+ <button type="button" class="pvd-addfilter-item" onclick={() => addFilterField(f.field)}>{f.label}</button>
1239
+ {/each}
1240
+ {#if !availableFilterFields.length}<div class="pvd-empty">No columns</div>{/if}
1241
+ </div>
1242
+ </div>
1243
+ {/if}
1244
+ </div>
1245
+ </div>
1246
+ {/snippet}
1247
+
1248
+ {#snippet gridBlock()}
1249
+ {#if embedGrid}
1250
+ <div class="pvd-grid" style={`height:${typeof gridHeight === 'number' ? gridHeight + 'px' : gridHeight}`} oncontextmenu={(e) => openCtx(e, gridMenu())}>
1251
+ {#if showPivotToggle && !pivotMode}
1252
+ <SvGrid
1253
+ data={filteredData}
1254
+ columns={visibleFlatColumns!}
1255
+ features={features}
1256
+ filterMode="row"
1257
+ sortable
1258
+ selectionMode="none"
1259
+ rowHeight={32}
1260
+ containerHeight="100%"
1261
+ fitColumns={gridFitColumns}
1262
+ columnVirtualization={columnVirtualization}
1263
+ enableRowSummaries={false}
1264
+ />
1265
+ {:else if pivot && chartable && view === 'chart'}
1266
+ {#if chartSpec && chartSpec.series.length}
1267
+ <div class="pvd-chart"><SvGridChart spec={chartSpec} interactive legend /></div>
1268
+ {:else}
1269
+ <div class="pvd-empty pvd-grid-empty">Add a measure and a row / column field to chart the pivot.</div>
1270
+ {/if}
1271
+ {:else if pivot}
1272
+ <SvGrid
1273
+ data={visibleRows}
1274
+ columns={finalColumns}
1275
+ features={features}
1276
+ sortable
1277
+ filterable
1278
+ selectionMode="none"
1279
+ rowHeight={32}
1280
+ containerHeight="100%"
1281
+ fitColumns={gridFitColumns}
1282
+ columnVirtualization={columnVirtualization}
1283
+ enableRowSummaries={false}
1284
+ {onCellClick}
1285
+ />
1286
+ {:else}
1287
+ <div class="pvd-empty pvd-grid-empty">
1288
+ {#if !layout.values.length}
1289
+ Drop at least one <strong>measure</strong> into Values to render the pivot.
1290
+ {:else}
1291
+ No data.
1292
+ {/if}
1293
+ </div>
1294
+ {/if}
1295
+ </div>
1296
+ {/if}
1297
+ {/snippet}
1298
+
1299
+ <!-- Label cell renderer used when `expandable` is on. Indents by
1300
+ pivot depth and shows a clickable chevron for expandable rows
1301
+ (group rows that have descendants). -->
1302
+ {#snippet ChevronLabelCell({ row, collapsed: isCollapsed, onToggle }: { row: PivotRow; collapsed: boolean; onToggle: () => void })}
1303
+ <span class="pvd-label-cell" style={`padding-left:${row.__pivotDepth * 14}px`}>
1304
+ {#if row.__pivotExpandable}
1305
+ <button
1306
+ type="button"
1307
+ class="pvd-chev"
1308
+ class:is-collapsed={isCollapsed}
1309
+ onclick={(e) => { e.stopPropagation(); onToggle() }}
1310
+ aria-label={isCollapsed ? 'Expand' : 'Collapse'}
1311
+ >▾</button>
1312
+ {:else}
1313
+ <span class="pvd-chev pvd-chev-placeholder"></span>
1314
+ {/if}
1315
+ <span class="pvd-label-text" class:is-subtotal={row.__pivotKind === 'subtotal'} class:is-grand={row.__pivotKind === 'grandTotal'}>
1316
+ {row.__pivotLabel}
1317
+ </span>
1318
+ </span>
1319
+ {/snippet}
1320
+
1321
+ <style>
1322
+ /* SvPivotDesigner styles -------------------------------------------
1323
+ All variables use the SvGrid token system (--sg-*) with safe
1324
+ fallbacks so the component theme-matches whatever grid skin the
1325
+ host page is using. */
1326
+ .pvd {
1327
+ display: flex;
1328
+ flex-direction: column;
1329
+ width: 100%;
1330
+ height: 100%;
1331
+ min-height: 0;
1332
+ color: var(--sg-fg, #0f172a);
1333
+ background: var(--sg-bg, #ffffff);
1334
+ border: 1px solid var(--sg-border, #e2e8f0);
1335
+ border-radius: 8px;
1336
+ overflow: hidden;
1337
+ font-family: inherit;
1338
+ }
1339
+ .pvd-toolbar {
1340
+ display: flex;
1341
+ align-items: center;
1342
+ gap: 8px;
1343
+ padding: 8px 12px;
1344
+ background: var(--sg-header-bg, #f8fafc);
1345
+ border-bottom: 1px solid var(--sg-border, #e2e8f0);
1346
+ flex-shrink: 0;
1347
+ }
1348
+ .pvd-title {
1349
+ font-size: 13px;
1350
+ font-weight: 700;
1351
+ color: var(--sg-fg);
1352
+ margin-right: 8px;
1353
+ }
1354
+ .pvd-spacer { flex: 1; }
1355
+ .pvd-btn {
1356
+ display: inline-flex;
1357
+ align-items: center;
1358
+ gap: 5px;
1359
+ border: 1px solid var(--sg-border, #cbd5e1);
1360
+ background: var(--sg-bg, #ffffff);
1361
+ color: var(--sg-fg, #1e293b);
1362
+ padding: 4px 10px;
1363
+ border-radius: 5px;
1364
+ font-size: 12px;
1365
+ font-weight: 600;
1366
+ cursor: pointer;
1367
+ transition: background 100ms ease, border-color 100ms ease;
1368
+ }
1369
+ .pvd-btn .pvd-ic { color: var(--sg-muted, #64748b); }
1370
+ .pvd-btn-primary .pvd-ic { color: currentColor; }
1371
+ .pvd-btn:hover { background: var(--sg-row-hover-bg, #f1f5f9); border-color: var(--sg-accent, #2563eb); }
1372
+ .pvd-btn-primary {
1373
+ background: var(--sg-accent, #2563eb);
1374
+ color: #fff;
1375
+ border-color: var(--sg-accent, #2563eb);
1376
+ }
1377
+ .pvd-btn-primary:hover { opacity: 0.9; }
1378
+ .pvd-toggle {
1379
+ display: inline-flex;
1380
+ align-items: center;
1381
+ gap: 4px;
1382
+ font-size: 12px;
1383
+ color: var(--sg-fg);
1384
+ cursor: pointer;
1385
+ user-select: none;
1386
+ }
1387
+ .pvd-toggle input { accent-color: var(--sg-accent, #2563eb); }
1388
+
1389
+ .pvd-body {
1390
+ display: grid;
1391
+ grid-template-columns: 220px 1fr;
1392
+ gap: 0;
1393
+ flex: 1;
1394
+ min-height: 0;
1395
+ }
1396
+ .pvd-body.no-rail { grid-template-columns: 1fr; }
1397
+
1398
+ /* Left rail */
1399
+ .pvd-rail {
1400
+ display: flex;
1401
+ flex-direction: column;
1402
+ border-right: 1px solid var(--sg-border, #e2e8f0);
1403
+ background: var(--sg-bg, #ffffff);
1404
+ min-height: 0;
1405
+ }
1406
+ .pvd-search {
1407
+ border: 0;
1408
+ border-bottom: 1px solid var(--sg-border, #e2e8f0);
1409
+ padding: 8px 12px;
1410
+ font-size: 12px;
1411
+ background: transparent;
1412
+ color: var(--sg-fg);
1413
+ outline: none;
1414
+ }
1415
+ .pvd-search:focus { background: var(--sg-row-hover-bg, #f1f5f9); }
1416
+ .pvd-fieldlist {
1417
+ flex: 1;
1418
+ min-height: 0;
1419
+ overflow: auto;
1420
+ padding: 4px 0;
1421
+ }
1422
+ .pvd-group-head {
1423
+ padding: 8px 12px 4px;
1424
+ font-size: 10.5px;
1425
+ font-weight: 700;
1426
+ text-transform: uppercase;
1427
+ letter-spacing: 0.06em;
1428
+ color: var(--sg-muted, #64748b);
1429
+ }
1430
+ .pvd-field {
1431
+ display: grid;
1432
+ grid-template-columns: 16px auto 1fr;
1433
+ align-items: center;
1434
+ gap: 8px;
1435
+ padding: 5px 12px;
1436
+ font-size: 12px;
1437
+ cursor: grab;
1438
+ transition: background 80ms ease;
1439
+ }
1440
+ .pvd-field:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
1441
+ .pvd-field:active { cursor: grabbing; }
1442
+ .pvd-field.in-use .pvd-field-label { font-weight: 600; color: var(--sg-accent, #2563eb); }
1443
+ .pvd-field-label {
1444
+ overflow: hidden;
1445
+ text-overflow: ellipsis;
1446
+ white-space: nowrap;
1447
+ }
1448
+ /* Dimension vs measure indicator - a tinted icon puck. */
1449
+ .pvd-field-kind {
1450
+ display: inline-flex;
1451
+ align-items: center;
1452
+ justify-content: center;
1453
+ width: 20px;
1454
+ height: 20px;
1455
+ border-radius: 4px;
1456
+ color: var(--sg-accent, #2563eb);
1457
+ background: color-mix(in srgb, var(--sg-accent, #2563eb) 12%, transparent);
1458
+ }
1459
+ .pvd-field-kind.is-measure {
1460
+ color: #0e9f6e;
1461
+ background: color-mix(in srgb, #0e9f6e 12%, transparent);
1462
+ }
1463
+
1464
+ /* Base icon sizing (inline SVGs from the `ic` snippet). */
1465
+ :global(.pvd .pvd-ic),
1466
+ :global(.pvd-ctx .pvd-ic) {
1467
+ width: 14px;
1468
+ height: 14px;
1469
+ flex: none;
1470
+ display: block;
1471
+ }
1472
+
1473
+ /* ---- Columns tool panel: visibility tree (columnTree) ------------ */
1474
+ .pvd-tree-top {
1475
+ display: flex;
1476
+ align-items: center;
1477
+ gap: 8px;
1478
+ padding: 10px 12px 6px;
1479
+ }
1480
+ .pvd-tree-search {
1481
+ flex: 1;
1482
+ border: 1px solid var(--sg-input-border, var(--sg-border, #e2e8f0));
1483
+ border-radius: var(--sg-radius, 6px);
1484
+ padding: 6px 9px;
1485
+ }
1486
+ .pvd-tree-check { accent-color: var(--sg-accent, #2563eb); width: 14px; height: 14px; flex: none; cursor: pointer; }
1487
+ .pvd-tree { padding: 2px 0 6px; }
1488
+ .pvd-tree-group {
1489
+ display: flex;
1490
+ align-items: center;
1491
+ gap: 6px;
1492
+ padding: 4px 12px 4px 6px;
1493
+ cursor: default;
1494
+ }
1495
+ .pvd-tree-group:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
1496
+ .pvd-tree-group-name {
1497
+ font-size: 12px;
1498
+ font-weight: 700;
1499
+ color: var(--sg-fg, #0f172a);
1500
+ overflow: hidden;
1501
+ text-overflow: ellipsis;
1502
+ white-space: nowrap;
1503
+ }
1504
+ .pvd-tree-twisty {
1505
+ display: inline-flex;
1506
+ align-items: center;
1507
+ justify-content: center;
1508
+ width: 16px;
1509
+ height: 16px;
1510
+ padding: 0;
1511
+ border: 0;
1512
+ background: transparent;
1513
+ color: var(--sg-muted, #64748b);
1514
+ cursor: pointer;
1515
+ transition: transform 100ms ease;
1516
+ }
1517
+ .pvd-tree-twisty:not(.is-open) { transform: rotate(-90deg); }
1518
+ .pvd-tree-leaf {
1519
+ display: flex;
1520
+ align-items: center;
1521
+ gap: 8px;
1522
+ /* Indent past the twisty so leaves nest under their group. */
1523
+ padding: 4px 12px 4px 30px;
1524
+ font-size: 12px;
1525
+ cursor: grab;
1526
+ transition: background 80ms ease;
1527
+ }
1528
+ .pvd-tree-leaf:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
1529
+ .pvd-tree-leaf:active { cursor: grabbing; }
1530
+ .pvd-tree-leaf .pvd-field-label { flex: 1; }
1531
+ .pvd-tree .pvd-grip { display: inline-flex; align-items: center; color: var(--sg-muted, #cbd5e1); }
1532
+ .pvd-tree .pvd-grip .pvd-ic { width: 12px; height: 12px; }
1533
+
1534
+ /* Main area */
1535
+ .pvd-main {
1536
+ display: flex;
1537
+ flex-direction: column;
1538
+ min-height: 0;
1539
+ min-width: 0;
1540
+ }
1541
+ .pvd-wells {
1542
+ display: grid;
1543
+ grid-template-columns: repeat(4, 1fr);
1544
+ gap: 8px;
1545
+ padding: 8px;
1546
+ background: var(--sg-bg, #ffffff);
1547
+ border-bottom: 1px solid var(--sg-border, #e2e8f0);
1548
+ }
1549
+ .pvd-wells.two { grid-template-columns: repeat(3, 1fr); }
1550
+
1551
+ .pvd-well {
1552
+ display: flex;
1553
+ flex-direction: column;
1554
+ border: 1px dashed var(--sg-border, #cbd5e1);
1555
+ border-radius: 6px;
1556
+ background: var(--sg-bg, #ffffff);
1557
+ min-height: 64px;
1558
+ transition: border-color 100ms ease, background 100ms ease;
1559
+ }
1560
+ .pvd-well.drag-over {
1561
+ border-color: var(--sg-accent, #2563eb);
1562
+ border-style: solid;
1563
+ background: color-mix(in srgb, var(--sg-accent, #2563eb) 8%, var(--sg-bg, #ffffff));
1564
+ }
1565
+ .pvd-well-head {
1566
+ display: flex;
1567
+ align-items: center;
1568
+ gap: 5px;
1569
+ padding: 4px 8px 2px;
1570
+ font-size: 10.5px;
1571
+ font-weight: 700;
1572
+ text-transform: uppercase;
1573
+ letter-spacing: 0.06em;
1574
+ color: var(--sg-muted, #64748b);
1575
+ }
1576
+ .pvd-well-head .pvd-ic { color: var(--sg-accent, #2563eb); }
1577
+ .pvd-well-body {
1578
+ display: flex;
1579
+ flex-wrap: wrap;
1580
+ gap: 4px;
1581
+ padding: 4px 8px 8px;
1582
+ min-height: 36px;
1583
+ align-content: flex-start;
1584
+ }
1585
+ .pvd-well-hint {
1586
+ color: var(--sg-muted, #94a3b8);
1587
+ font-size: 11px;
1588
+ font-style: italic;
1589
+ padding: 4px 0;
1590
+ }
1591
+
1592
+ .pvd-chip {
1593
+ position: relative;
1594
+ display: inline-flex;
1595
+ align-items: center;
1596
+ gap: 2px;
1597
+ background: var(--sg-header-bg, #f1f5f9);
1598
+ border: 1px solid var(--sg-border, #cbd5e1);
1599
+ border-radius: 5px;
1600
+ font-size: 12px;
1601
+ color: var(--sg-fg, #0f172a);
1602
+ cursor: grab;
1603
+ user-select: none;
1604
+ }
1605
+ .pvd-chip:active { cursor: grabbing; }
1606
+ .pvd-chip-value {
1607
+ background: color-mix(in srgb, var(--sg-accent, #2563eb) 14%, var(--sg-bg, #ffffff));
1608
+ border-color: var(--sg-accent, #2563eb);
1609
+ }
1610
+ .pvd-chip-grip {
1611
+ display: inline-flex;
1612
+ align-items: center;
1613
+ padding-left: 4px;
1614
+ color: var(--sg-muted, #94a3b8);
1615
+ cursor: grab;
1616
+ }
1617
+ .pvd-chip-grip .pvd-ic { width: 12px; height: 12px; }
1618
+ .pvd-chip-label {
1619
+ border: 0;
1620
+ background: transparent;
1621
+ padding: 4px 6px 4px 4px;
1622
+ font: inherit;
1623
+ color: inherit;
1624
+ cursor: pointer;
1625
+ }
1626
+ .pvd-chip-agg {
1627
+ font-size: 10.5px;
1628
+ font-weight: 700;
1629
+ color: var(--sg-accent, #2563eb);
1630
+ text-transform: uppercase;
1631
+ letter-spacing: 0.04em;
1632
+ margin-right: 4px;
1633
+ }
1634
+ .pvd-chip-sep { color: var(--sg-muted, #94a3b8); margin-right: 4px; }
1635
+ .pvd-chip-x {
1636
+ border: 0;
1637
+ background: transparent;
1638
+ color: var(--sg-muted, #64748b);
1639
+ cursor: pointer;
1640
+ padding: 2px 6px 3px;
1641
+ font-size: 14px;
1642
+ line-height: 1;
1643
+ border-radius: 3px;
1644
+ }
1645
+ .pvd-chip-x:hover { color: #ef4444; background: rgba(239, 68, 68, 0.08); }
1646
+
1647
+ /* Popovers (agg menu, filter menu, presets) */
1648
+ .pvd-popover, .pvd-presets {
1649
+ position: relative;
1650
+ }
1651
+ .pvd-popover {
1652
+ position: absolute;
1653
+ top: calc(100% + 4px);
1654
+ left: 0;
1655
+ z-index: 50;
1656
+ min-width: 160px;
1657
+ background: var(--sg-bg, #ffffff);
1658
+ border: 1px solid var(--sg-border, #cbd5e1);
1659
+ border-radius: 6px;
1660
+ box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
1661
+ padding: 4px;
1662
+ }
1663
+ .pvd-popover-filter { min-width: 200px; max-height: 280px; display: flex; flex-direction: column; }
1664
+ .pvd-popover-head {
1665
+ display: flex; gap: 4px;
1666
+ padding: 4px;
1667
+ border-bottom: 1px solid var(--sg-border, #e2e8f0);
1668
+ }
1669
+ .pvd-popover-mini {
1670
+ flex: 1;
1671
+ border: 1px solid var(--sg-border, #cbd5e1);
1672
+ background: var(--sg-bg, #ffffff);
1673
+ color: var(--sg-fg);
1674
+ padding: 2px 6px;
1675
+ border-radius: 4px;
1676
+ font-size: 11px;
1677
+ cursor: pointer;
1678
+ }
1679
+ .pvd-popover-mini:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
1680
+ .pvd-popover-list { flex: 1; min-height: 0; overflow: auto; padding: 4px 0; }
1681
+ .pvd-popover-item {
1682
+ display: block;
1683
+ width: 100%;
1684
+ text-align: left;
1685
+ border: 0;
1686
+ background: transparent;
1687
+ padding: 5px 10px;
1688
+ font-size: 12px;
1689
+ color: var(--sg-fg);
1690
+ cursor: pointer;
1691
+ border-radius: 4px;
1692
+ }
1693
+ .pvd-popover-item:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
1694
+ .pvd-popover-item.is-active { background: color-mix(in srgb, var(--sg-accent, #2563eb) 14%, transparent); color: var(--sg-accent, #2563eb); font-weight: 600; }
1695
+ .pvd-popover-check {
1696
+ display: flex; align-items: center; gap: 8px;
1697
+ cursor: pointer;
1698
+ }
1699
+ .pvd-popover-check input { accent-color: var(--sg-accent, #2563eb); }
1700
+
1701
+ /* Embedded grid */
1702
+ .pvd-grid {
1703
+ flex: 1;
1704
+ min-height: 0;
1705
+ padding: 0;
1706
+ }
1707
+ .pvd-grid-empty {
1708
+ display: flex;
1709
+ align-items: center;
1710
+ justify-content: center;
1711
+ height: 100%;
1712
+ color: var(--sg-muted, #94a3b8);
1713
+ font-size: 13px;
1714
+ padding: 24px;
1715
+ text-align: center;
1716
+ }
1717
+
1718
+ .pvd-empty {
1719
+ padding: 20px;
1720
+ color: var(--sg-muted, #94a3b8);
1721
+ font-size: 12px;
1722
+ text-align: center;
1723
+ }
1724
+
1725
+ /* Built-in label cell renderer (when expandable is on) */
1726
+ :global(.pvd-label-cell) {
1727
+ display: inline-flex;
1728
+ align-items: center;
1729
+ gap: 4px;
1730
+ line-height: 1;
1731
+ }
1732
+ :global(.pvd-chev) {
1733
+ width: 14px;
1734
+ height: 14px;
1735
+ border: 0;
1736
+ background: transparent;
1737
+ color: var(--sg-muted, #64748b);
1738
+ font-size: 10px;
1739
+ line-height: 14px;
1740
+ cursor: pointer;
1741
+ border-radius: 3px;
1742
+ padding: 0;
1743
+ transition: transform 100ms ease, background 100ms ease;
1744
+ }
1745
+ :global(.pvd-chev:hover) { background: var(--sg-row-hover-bg, #f1f5f9); color: var(--sg-fg, #0f172a); }
1746
+ :global(.pvd-chev.is-collapsed) { transform: rotate(-90deg); }
1747
+ :global(.pvd-chev-placeholder) { cursor: default; visibility: hidden; }
1748
+ :global(.pvd-label-text.is-subtotal) { font-weight: 700; }
1749
+ :global(.pvd-label-text.is-grand) { font-weight: 800; color: var(--sg-accent, #2563eb); }
1750
+
1751
+ /* Mobile: stack rail above main, wells in two columns. */
1752
+ @media (max-width: 900px) {
1753
+ .pvd-body { grid-template-columns: 1fr; }
1754
+ .pvd-rail {
1755
+ border-right: 0;
1756
+ border-bottom: 1px solid var(--sg-border, #e2e8f0);
1757
+ max-height: 200px;
1758
+ }
1759
+ .pvd-wells, .pvd-wells.two { grid-template-columns: repeat(2, 1fr); }
1760
+ }
1761
+ .pvd-chip-caret { font-size: 9px; color: var(--sg-muted, #94a3b8); }
1762
+ .pvd-select {
1763
+ font: inherit; font-size: 12px; color: var(--sg-fg);
1764
+ background: var(--sg-input-bg, var(--sg-bg, #fff));
1765
+ border: 1px solid var(--sg-input-border, var(--sg-border, #e2e8f0));
1766
+ border-radius: var(--sg-radius, 6px); padding: 3px 6px;
1767
+ }
1768
+ .pvd-viewswitch { display: inline-flex; border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius, 6px); overflow: hidden; }
1769
+ .pvd-view-btn { display: inline-flex; align-items: center; gap: 5px; font: inherit; font-size: 12px; padding: 3px 12px; border: 0; background: var(--sg-bg, #fff); color: var(--sg-muted, #64748b); cursor: pointer; }
1770
+ .pvd-view-btn.is-active { background: var(--sg-accent, #2563eb); color: #fff; }
1771
+ .pvd-chart { width: 100%; height: 100%; padding: 8px 10px; box-sizing: border-box; overflow: hidden; display: flex; }
1772
+ .pvd-chart > :global(.sv-grid-chart) { width: 100%; }
1773
+
1774
+ /* ---- Right-docked tool panel (panelPosition="right") -------------- */
1775
+ .pvd-body[data-panel='right'] {
1776
+ display: flex;
1777
+ flex-direction: row;
1778
+ }
1779
+ .pvd-gridwrap {
1780
+ flex: 1;
1781
+ min-width: 0;
1782
+ min-height: 0;
1783
+ display: flex;
1784
+ flex-direction: column;
1785
+ }
1786
+ .pvd-gridwrap .pvd-grid { flex: 1; min-height: 0; }
1787
+ .pvd-panel {
1788
+ flex-shrink: 0;
1789
+ display: flex;
1790
+ flex-direction: column;
1791
+ min-height: 0;
1792
+ border-left: 1px solid var(--sg-border, #e2e8f0);
1793
+ background: var(--sg-header-bg, #f8fafc);
1794
+ overflow-y: auto;
1795
+ }
1796
+ /* Tabbed panel: content column + a vertical Columns/Filters rail on the right. */
1797
+ .pvd-panel-tabbed {
1798
+ flex-direction: row;
1799
+ overflow: hidden;
1800
+ }
1801
+ .pvd-tabbody {
1802
+ flex: 1;
1803
+ min-width: 0;
1804
+ min-height: 0;
1805
+ display: flex;
1806
+ flex-direction: column;
1807
+ overflow-y: auto;
1808
+ }
1809
+ .pvd-tabrail {
1810
+ flex: none;
1811
+ display: flex;
1812
+ flex-direction: column;
1813
+ border-left: 1px solid var(--sg-border, #e2e8f0);
1814
+ background: var(--sg-bg, #fff);
1815
+ }
1816
+ .pvd-tabbtn {
1817
+ display: flex;
1818
+ flex-direction: column;
1819
+ align-items: center;
1820
+ gap: 6px;
1821
+ padding: 12px 7px;
1822
+ border: 0;
1823
+ border-left: 2px solid transparent;
1824
+ background: transparent;
1825
+ color: var(--sg-muted, #64748b);
1826
+ cursor: pointer;
1827
+ font-size: 11.5px;
1828
+ font-weight: 600;
1829
+ }
1830
+ .pvd-tabbtn span { writing-mode: vertical-rl; }
1831
+ .pvd-tabbtn:hover { color: var(--sg-fg, #0f172a); background: var(--sg-row-hover-bg, #f1f5f9); }
1832
+ .pvd-tabbtn.is-active {
1833
+ color: var(--sg-accent, #2563eb);
1834
+ border-left-color: var(--sg-accent, #2563eb);
1835
+ background: var(--sg-header-bg, #f8fafc);
1836
+ }
1837
+ .pvd-tabbtn.is-active .pvd-ic { color: var(--sg-accent, #2563eb); }
1838
+
1839
+ /* ---- Filters tab (set filters) ---- */
1840
+ .pvd-filters-tab { display: flex; flex-direction: column; gap: 8px; padding: 10px; }
1841
+ .pvd-filt-card {
1842
+ border: 1px solid var(--sg-border, #e2e8f0);
1843
+ border-radius: 8px;
1844
+ background: var(--sg-bg, #fff);
1845
+ overflow: hidden;
1846
+ }
1847
+ .pvd-filt-head {
1848
+ display: flex; align-items: center;
1849
+ border-bottom: 1px solid var(--sg-border, #e2e8f0);
1850
+ }
1851
+ .pvd-filt-title {
1852
+ flex: 1; min-width: 0;
1853
+ display: inline-flex; align-items: center; gap: 5px;
1854
+ padding: 7px 8px;
1855
+ border: 0; background: transparent;
1856
+ font-size: 12.5px; font-weight: 600; color: var(--sg-fg, #0f172a);
1857
+ cursor: pointer; text-align: left;
1858
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1859
+ }
1860
+ .pvd-filt-twisty { display: inline-flex; color: var(--sg-muted, #64748b); transition: transform 100ms ease; }
1861
+ .pvd-filt-twisty:not(.is-open) { transform: rotate(-90deg); }
1862
+ .pvd-filt-x {
1863
+ border: 0; background: transparent; color: var(--sg-muted, #94a3b8);
1864
+ cursor: pointer; font-size: 15px; line-height: 1; padding: 4px 9px;
1865
+ }
1866
+ .pvd-filt-x:hover { color: #ef4444; }
1867
+ .pvd-filt-search {
1868
+ width: 100%; box-sizing: border-box;
1869
+ border: 0; border-bottom: 1px solid var(--sg-border, #e2e8f0);
1870
+ padding: 7px 9px; font-size: 12px; background: transparent; color: var(--sg-fg, #0f172a);
1871
+ outline: none;
1872
+ }
1873
+ .pvd-filt-list { max-height: 220px; overflow-y: auto; }
1874
+ .pvd-filt-opt {
1875
+ display: flex; align-items: center; gap: 8px;
1876
+ padding: 4px 10px; font-size: 12.5px; color: var(--sg-fg, #0f172a); cursor: pointer;
1877
+ }
1878
+ .pvd-filt-opt:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
1879
+ .pvd-filt-opt span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1880
+ .pvd-filt-opt input { accent-color: var(--sg-accent, #2563eb); flex: none; }
1881
+ .pvd-filt-all { font-weight: 600; border-bottom: 1px solid var(--sg-border, #e2e8f0); }
1882
+
1883
+ .pvd-addfilter { position: relative; }
1884
+ .pvd-addfilter-btn {
1885
+ display: inline-flex; align-items: center; gap: 6px;
1886
+ width: 100%; box-sizing: border-box;
1887
+ padding: 8px 10px;
1888
+ border: 1px solid var(--sg-border, #cbd5e1); border-radius: 8px;
1889
+ background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
1890
+ font-size: 12.5px; font-weight: 600; cursor: pointer;
1891
+ }
1892
+ .pvd-addfilter-btn:hover { border-color: var(--sg-accent, #2563eb); background: var(--sg-row-hover-bg, #f1f5f9); }
1893
+ .pvd-addfilter-btn .pvd-ic { color: var(--sg-muted, #64748b); }
1894
+ .pvd-addfilter-menu {
1895
+ position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 60;
1896
+ background: var(--sg-bg, #fff);
1897
+ border: 1px solid var(--sg-border, #cbd5e1); border-radius: 8px;
1898
+ box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
1899
+ overflow: hidden;
1900
+ }
1901
+ .pvd-addfilter-searchwrap {
1902
+ display: flex; align-items: center; gap: 6px;
1903
+ padding: 7px 9px; border-bottom: 1px solid var(--sg-border, #e2e8f0);
1904
+ color: var(--sg-muted, #94a3b8);
1905
+ }
1906
+ .pvd-addfilter-search { flex: 1; border: 0; outline: none; background: transparent; color: var(--sg-fg, #0f172a); font-size: 12px; }
1907
+ .pvd-addfilter-list { max-height: 240px; overflow-y: auto; padding: 4px 0; }
1908
+ .pvd-addfilter-item {
1909
+ display: block; width: 100%; text-align: left;
1910
+ border: 0; background: transparent; color: var(--sg-fg, #0f172a);
1911
+ padding: 6px 12px; font-size: 12.5px; cursor: pointer;
1912
+ }
1913
+ .pvd-addfilter-item:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
1914
+ .pvd-panel-rail {
1915
+ display: flex;
1916
+ flex-direction: column;
1917
+ flex: 1 1 auto;
1918
+ min-height: 120px;
1919
+ border-bottom: 1px solid var(--sg-border, #e2e8f0);
1920
+ }
1921
+ .pvd-panel-rail .pvd-search {
1922
+ background: var(--sg-input-bg, var(--sg-bg, #fff));
1923
+ border: 1px solid var(--sg-input-border, var(--sg-border, #e2e8f0));
1924
+ border-radius: var(--sg-radius, 6px);
1925
+ margin: 10px 10px 6px;
1926
+ padding: 6px 9px;
1927
+ }
1928
+ .pvd-panel-rail .pvd-fieldlist { padding-bottom: 8px; }
1929
+ .pvd-wells--vertical {
1930
+ display: flex;
1931
+ flex-direction: column;
1932
+ gap: 8px;
1933
+ padding: 10px;
1934
+ border-bottom: 0;
1935
+ flex: 0 0 auto;
1936
+ }
1937
+ .pvd-wells--vertical .pvd-well { min-height: 56px; background: var(--sg-bg, #fff); }
1938
+
1939
+ /* ---- Pivot Mode toggle ------------------------------------------- */
1940
+ .pvd-topbar {
1941
+ padding: 8px 10px;
1942
+ border-bottom: 1px solid var(--sg-border, #e2e8f0);
1943
+ background: var(--sg-header-bg, #f8fafc);
1944
+ }
1945
+ .pvd-panel > .pvd-pivot-toggle {
1946
+ padding: 12px;
1947
+ border-bottom: 1px solid var(--sg-border, #e2e8f0);
1948
+ }
1949
+ .pvd-pivot-toggle {
1950
+ display: flex;
1951
+ align-items: center;
1952
+ gap: 8px;
1953
+ cursor: pointer;
1954
+ user-select: none;
1955
+ }
1956
+ .pvd-pivot-toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
1957
+ .pvd-switch {
1958
+ width: 34px; height: 18px; border-radius: 999px; flex-shrink: 0;
1959
+ background: var(--sg-border, #cbd5e1);
1960
+ position: relative; transition: background 120ms ease;
1961
+ }
1962
+ .pvd-switch.on { background: var(--sg-accent, #2563eb); }
1963
+ .pvd-switch-knob {
1964
+ position: absolute; top: 2px; left: 2px;
1965
+ width: 14px; height: 14px; border-radius: 999px; background: #fff;
1966
+ transition: transform 120ms ease;
1967
+ }
1968
+ .pvd-switch.on .pvd-switch-knob { transform: translateX(16px); }
1969
+ .pvd-pivot-icon { display: inline-flex; align-items: center; color: var(--sg-accent, #2563eb); }
1970
+ .pvd-pivot-label { font-size: 13px; font-weight: 600; color: var(--sg-fg, #0f172a); }
1971
+
1972
+ /* ---- Right-click context menu panel ------------------------------ */
1973
+ :global(.pvd-ctx) {
1974
+ z-index: 2147483646;
1975
+ min-width: 200px;
1976
+ background: var(--sg-bg, #fff);
1977
+ color: var(--sg-fg, #0f172a);
1978
+ border: 1px solid var(--sg-border, #e2e8f0);
1979
+ border-radius: 10px;
1980
+ box-shadow: 0 16px 48px -12px rgba(15, 23, 42, 0.35);
1981
+ font-size: 13px;
1982
+ }
1983
+
1984
+ @media (max-width: 900px) {
1985
+ .pvd-body[data-panel='right'] { flex-direction: column; }
1986
+ .pvd-panel { width: auto !important; border-left: 0; border-top: 1px solid var(--sg-border, #e2e8f0); }
1987
+ .pvd-wells--vertical { flex-direction: row; flex-wrap: wrap; }
1988
+ .pvd-wells--vertical .pvd-well { flex: 1 1 40%; }
1989
+ }
1990
+ </style>