@kubex/zinc 1.1.185 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (358) hide show
  1. package/.browserslistrc +2 -0
  2. package/.github/workflows/js_build_and_deploy.yaml +54 -1
  3. package/.github/workflows/test.yaml +27 -0
  4. package/dist/chunks/zn.UWJLIXDT.js +1 -0
  5. package/dist/custom-elements.json +9998 -2345
  6. package/dist/vscode.html-custom-data.json +1022 -67
  7. package/dist/web-types.json +2129 -177
  8. package/dist/zn.d.ts +2293 -416
  9. package/dist/zn.min.css +1 -1
  10. package/dist/zn.min.js +2310 -1637
  11. package/docs/_includes/default.njk +4 -1
  12. package/docs/assets/scripts/docs.js +22 -6
  13. package/docs/assets/styles/docs.css +9 -2
  14. package/docs/data/report-table-day-compare.json +380 -0
  15. package/docs/data/report-table.json +314 -0
  16. package/docs/pages/components/avatar-group.md +57 -0
  17. package/docs/pages/components/badge.md +67 -0
  18. package/docs/pages/components/button.md +41 -1
  19. package/docs/pages/components/chat-message.md +16 -0
  20. package/docs/pages/components/checkbox.md +1 -1
  21. package/docs/pages/components/chip.md +25 -13
  22. package/docs/pages/components/color-picker.md +37 -0
  23. package/docs/pages/components/copy-button.md +2 -2
  24. package/docs/pages/components/data-table-filter.md +33 -50
  25. package/docs/pages/components/data-table-search.md +4 -4
  26. package/docs/pages/components/data-table.md +27 -3
  27. package/docs/pages/components/date-range.md +116 -0
  28. package/docs/pages/components/dialog.md +29 -37
  29. package/docs/pages/components/diff-row.md +28 -0
  30. package/docs/pages/components/diff.md +46 -0
  31. package/docs/pages/components/editor.md +55 -0
  32. package/docs/pages/components/icon.md +46 -1
  33. package/docs/pages/components/input.md +30 -11
  34. package/docs/pages/components/item.md +35 -0
  35. package/docs/pages/components/key-value.md +25 -0
  36. package/docs/pages/components/menu.md +74 -7
  37. package/docs/pages/components/nav-item.md +15 -0
  38. package/docs/pages/components/nav-list.md +132 -0
  39. package/docs/pages/components/navbar.md +100 -1
  40. package/docs/pages/components/page.md +242 -74
  41. package/docs/pages/components/priority-list.md +43 -5
  42. package/docs/pages/components/query-builder.md +23 -0
  43. package/docs/pages/components/radio-group.md +33 -1
  44. package/docs/pages/components/radio.md +1 -1
  45. package/docs/pages/components/report-table.md +202 -0
  46. package/docs/pages/components/stat.md +30 -0
  47. package/docs/pages/components/tile.md +11 -0
  48. package/docs/pages/components/tooltip.md +1 -1
  49. package/docs/pages/components/translation-grid.md +86 -0
  50. package/docs/pages/components/vertical-stepper.md +108 -64
  51. package/package.json +2 -1
  52. package/scss/_global-spacing.scss +2 -2
  53. package/scss/_root.scss +11 -171
  54. package/scss/boot.scss +14 -0
  55. package/scss/mixins/_helper-mixins.scss +24 -2
  56. package/scss/mixins/_motion-mixins.scss +126 -0
  57. package/scss/mixins/_spacing-mixins.scss +4 -3
  58. package/scss/mixins/_tab-indicator-mixins.scss +12 -0
  59. package/scss/mixins/_typography-mixins.scss +1 -1
  60. package/scss/mixins/index.scss +14 -0
  61. package/scss/shared/_base.scss +32 -1
  62. package/scss/shared/_button-mixin.scss +7 -5
  63. package/scss/shared/_table.scss +35 -4
  64. package/scss/shared/nav.scss +4 -0
  65. package/scss/shared/shimmer.scss +1 -1
  66. package/scss/table-controls.scss +206 -0
  67. package/scss/themes/_dark.scss +1 -3
  68. package/scss/themes/_index.scss +1 -1
  69. package/scss/themes/_light.scss +46 -6
  70. package/scss/themes/_modes.scss +11 -11
  71. package/scss/themes/_openbyte.scss +944 -0
  72. package/scss/themes/_typography.scss +4 -2
  73. package/scss/variables/index.scss +0 -9
  74. package/src/components/alert/alert.component.ts +14 -4
  75. package/src/components/alert/alert.scss +55 -69
  76. package/src/components/alert/alert.test.ts +8 -0
  77. package/src/components/animated-button/animated-button.component.ts +3 -2
  78. package/src/components/animated-button/animated-button.scss +6 -23
  79. package/src/components/animated-button/animated-button.test.ts +4 -3
  80. package/src/components/avatar-group/avatar-group.component.ts +84 -0
  81. package/src/components/avatar-group/avatar-group.scss +54 -0
  82. package/src/components/avatar-group/avatar-group.test.ts +59 -0
  83. package/src/components/avatar-group/index.ts +12 -0
  84. package/src/components/badge/badge.component.ts +49 -0
  85. package/src/components/badge/badge.scss +80 -0
  86. package/src/components/badge/badge.test.ts +34 -0
  87. package/src/components/badge/index.ts +12 -0
  88. package/src/components/bulk-actions/bulk-actions.component.ts +14 -2
  89. package/src/components/button/button.component.ts +228 -8
  90. package/src/components/button/button.scss +115 -32
  91. package/src/components/button/button.test.ts +122 -0
  92. package/src/components/button-menu/button-menu.component.ts +32 -11
  93. package/src/components/button-menu/button-menu.scss +5 -1
  94. package/src/components/channel-tile/channel-tile.component.ts +34 -27
  95. package/src/components/channel-tile/channel-tile.scss +76 -43
  96. package/src/components/chart/builders.test.ts +46 -0
  97. package/src/components/chart/builders.ts +27 -3
  98. package/src/components/chart/chart-live.test.ts +4 -6
  99. package/src/components/chart/chart.component.ts +125 -5
  100. package/src/components/chart/chart.scss +121 -0
  101. package/src/components/chart/chart.test.ts +81 -0
  102. package/src/components/chat-message/chat-message.component.ts +21 -1
  103. package/src/components/chat-message/chat-message.scss +13 -2
  104. package/src/components/chat-message/chat-message.test.ts +9 -0
  105. package/src/components/checkbox/checkbox.component.ts +61 -27
  106. package/src/components/checkbox/checkbox.scss +58 -25
  107. package/src/components/checkbox-group/checkbox-group.component.ts +7 -1
  108. package/src/components/chip/chip.component.ts +11 -1
  109. package/src/components/chip/chip.scss +91 -26
  110. package/src/components/chip/chip.test.ts +17 -0
  111. package/src/components/collapsible/collapsible.component.ts +17 -3
  112. package/src/components/collapsible/collapsible.scss +19 -10
  113. package/src/components/color-picker/color-picker.component.ts +277 -0
  114. package/src/components/color-picker/color-picker.scss +141 -0
  115. package/src/components/color-picker/color-picker.test.ts +57 -0
  116. package/src/components/color-picker/index.ts +12 -0
  117. package/src/components/cols/cols.component.ts +10 -0
  118. package/src/components/cols/cols.test.ts +17 -0
  119. package/src/components/confirm/confirm.component.ts +20 -6
  120. package/src/components/confirm/confirm.test.ts +52 -0
  121. package/src/components/content-block/content-block.component.ts +38 -9
  122. package/src/components/content-block/content-block.scss +11 -5
  123. package/src/components/content-block/content-block.test.ts +26 -0
  124. package/src/components/copy-button/copy-button.component.ts +2 -2
  125. package/src/components/copy-button/copy-button.scss +17 -4
  126. package/src/components/data-select/data-select.component.ts +3 -1
  127. package/src/components/data-table/data-table.component.ts +362 -140
  128. package/src/components/data-table/data-table.scss +133 -60
  129. package/src/components/data-table/data-table.test.ts +356 -4
  130. package/src/components/data-table-filter/data-table-filter.component.ts +426 -406
  131. package/src/components/data-table-filter/data-table-filter.scss +176 -148
  132. package/src/components/data-table-filter/data-table-filter.test.ts +271 -273
  133. package/src/components/data-table-search/data-table-search.component.ts +4 -2
  134. package/src/components/data-table-search/data-table-search.scss +8 -1
  135. package/src/components/data-table-search/data-table-search.test.ts +29 -0
  136. package/src/components/date-range/date-range-math.test.ts +78 -0
  137. package/src/components/date-range/date-range-math.ts +105 -0
  138. package/src/components/date-range/date-range.component.ts +375 -0
  139. package/src/components/date-range/date-range.scss +272 -0
  140. package/src/components/date-range/date-range.test.ts +247 -0
  141. package/src/components/date-range/index.ts +12 -0
  142. package/src/components/datepicker/datepicker.component.ts +44 -7
  143. package/src/components/datepicker/datepicker.scss +9 -0
  144. package/src/components/datepicker/datepicker.test.ts +2 -2
  145. package/src/components/dialog/dialog.component.ts +38 -19
  146. package/src/components/dialog/dialog.scss +65 -36
  147. package/src/components/dialog/dialog.test.ts +14 -0
  148. package/src/components/diff/diff.component.ts +55 -0
  149. package/src/components/diff/diff.scss +46 -0
  150. package/src/components/diff/diff.test.ts +15 -0
  151. package/src/components/diff/index.ts +12 -0
  152. package/src/components/diff-row/diff-row.component.ts +75 -0
  153. package/src/components/diff-row/diff-row.scss +70 -0
  154. package/src/components/diff-row/diff-row.test.ts +37 -0
  155. package/src/components/diff-row/index.ts +12 -0
  156. package/src/components/dropdown/dropdown.component.ts +12 -10
  157. package/src/components/dropdown/dropdown.scss +2 -0
  158. package/src/components/editor/editor.component.ts +159 -11
  159. package/src/components/editor/editor.scss +250 -1
  160. package/src/components/editor/editor.test.ts +31 -3
  161. package/src/components/editor/modules/ai/panel/ai-panel.scss +0 -1
  162. package/src/components/editor/modules/attachment/attachment.ts +53 -31
  163. package/src/components/editor/modules/context-menu/context-menu.ts +17 -0
  164. package/src/components/editor/modules/dialog/dialog.component.ts +23 -1
  165. package/src/components/editor/modules/dialog/dialog.test.ts +28 -0
  166. package/src/components/editor/modules/emoji/emoji.ts +4 -5
  167. package/src/components/editor/modules/merge-field/merge-field.ts +83 -0
  168. package/src/components/editor/modules/toolbar/toolbar.component.ts +121 -68
  169. package/src/components/editor/modules/toolbar/toolbar.scss +38 -0
  170. package/src/components/editor/modules/toolbar/toolbar.ts +25 -1
  171. package/src/components/empty-state/empty-state.component.ts +6 -1
  172. package/src/components/empty-state/empty-state.scss +21 -4
  173. package/src/components/expanding-action/expanding-action.component.ts +22 -34
  174. package/src/components/expanding-action/expanding-action.scss +3 -45
  175. package/src/components/expanding-action/expanding-action.test.ts +2 -1
  176. package/src/components/file/file.component.ts +30 -2
  177. package/src/components/file/file.scss +6 -0
  178. package/src/components/flow-builder/flow-builder.component.ts +36 -3
  179. package/src/components/flow-builder/flow-builder.scss +0 -6
  180. package/src/components/flow-builder/modules/flow-canvas/flow-canvas.component.ts +8 -0
  181. package/src/components/flow-builder/modules/flow-canvas/flow-canvas.scss +10 -5
  182. package/src/components/flow-builder/modules/flow-node/flow-node.component.ts +17 -0
  183. package/src/components/flow-builder/modules/flow-node/flow-node.scss +10 -0
  184. package/src/components/form-actions/form-actions.component.ts +48 -2
  185. package/src/components/form-actions/form-actions.scss +1 -1
  186. package/src/components/form-group/form-group.component.ts +10 -3
  187. package/src/components/form-group/form-group.scss +37 -4
  188. package/src/components/form-group/form-group.test.ts +10 -0
  189. package/src/components/header/header.component.ts +11 -4
  190. package/src/components/header/header.scss +47 -5
  191. package/src/components/hover-container/hover-container.component.ts +4 -5
  192. package/src/components/icon/icon.component.ts +95 -9
  193. package/src/components/icon/icon.scss +70 -6
  194. package/src/components/icon/icon.test.ts +55 -0
  195. package/src/components/icon-picker/icon-picker.component.ts +35 -1
  196. package/src/components/inline-edit/inline-edit.component.ts +38 -8
  197. package/src/components/inline-edit/inline-edit.scss +128 -19
  198. package/src/components/input/input.component.ts +173 -297
  199. package/src/components/input/input.scss +65 -43
  200. package/src/components/input/input.test.ts +86 -4
  201. package/src/components/item/item.component.ts +12 -0
  202. package/src/components/item/item.scss +102 -3
  203. package/src/components/item/item.test.ts +23 -0
  204. package/src/components/key/key.component.ts +2 -2
  205. package/src/components/key/key.scss +11 -0
  206. package/src/components/key-value/index.ts +12 -0
  207. package/src/components/key-value/key-value.component.ts +160 -0
  208. package/src/components/key-value/key-value.scss +20 -0
  209. package/src/components/key-value/key-value.test.ts +22 -0
  210. package/src/components/markdown-editor/markdown-editor.component.ts +8 -1
  211. package/src/components/menu/menu.component.ts +21 -2
  212. package/src/components/menu/menu.scss +43 -23
  213. package/src/components/menu/menu.test.ts +33 -0
  214. package/src/components/menu-item/menu-item.component.ts +13 -2
  215. package/src/components/menu-item/menu-item.scss +56 -23
  216. package/src/components/menu-item/menu-item.test.ts +31 -0
  217. package/src/components/menu-item/submenu-controller.ts +25 -15
  218. package/src/components/menu-label/index.ts +12 -0
  219. package/src/components/menu-label/menu-label.component.ts +30 -0
  220. package/src/components/menu-label/menu-label.scss +18 -0
  221. package/src/components/nav-item/index.ts +12 -0
  222. package/src/components/nav-item/nav-item.component.ts +83 -0
  223. package/src/components/nav-item/nav-item.scss +128 -0
  224. package/src/components/nav-list/index.ts +12 -0
  225. package/src/components/nav-list/nav-list.component.ts +313 -0
  226. package/src/components/nav-list/nav-list.scss +87 -0
  227. package/src/components/nav-list/nav-list.test.ts +250 -0
  228. package/src/components/navbar/navbar.component.ts +235 -171
  229. package/src/components/navbar/navbar.scss +333 -169
  230. package/src/components/navbar/navbar.test.ts +63 -143
  231. package/src/components/page/page.component.ts +114 -29
  232. package/src/components/page/page.scss +223 -95
  233. package/src/components/page/page.test.ts +214 -3
  234. package/src/components/page-builder/page-builder.component.ts +51 -5
  235. package/src/components/page-builder/page-builder.scss +6 -1
  236. package/src/components/page-nav/page-nav.component.ts +25 -3
  237. package/src/components/page-nav/page-nav.scss +10 -2
  238. package/src/components/pagination/pagination.scss +1 -1
  239. package/src/components/panel/panel.component.ts +14 -13
  240. package/src/components/panel/panel.scss +14 -10
  241. package/src/components/panel/panel.test.ts +42 -0
  242. package/src/components/popup/popup.component.ts +20 -13
  243. package/src/components/popup/popup.test.ts +21 -1
  244. package/src/components/priority-list/priority-list.component.ts +157 -172
  245. package/src/components/priority-list/priority-list.scss +113 -17
  246. package/src/components/priority-list/priority-list.test.ts +301 -3
  247. package/src/components/progress-bar/progress-bar.component.ts +1 -1
  248. package/src/components/progress-bar/progress-bar.scss +16 -4
  249. package/src/components/progress-tile/progress-tile.scss +1 -1
  250. package/src/components/query-builder/query-builder.component.ts +85 -10
  251. package/src/components/query-builder/query-builder.scss +7 -1
  252. package/src/components/query-builder/query-builder.test.ts +40 -0
  253. package/src/components/radio/radio.component.ts +57 -18
  254. package/src/components/radio/radio.scss +101 -26
  255. package/src/components/radio-group/radio-group.component.ts +45 -7
  256. package/src/components/radio-group/radio-group.scss +17 -0
  257. package/src/components/radio-group/radio-group.test.ts +63 -2
  258. package/src/components/rating/rating.component.ts +23 -0
  259. package/src/components/rating/rating.scss +1 -1
  260. package/src/components/remarkd-editor/remarkd-editor.component.ts +60 -5
  261. package/src/components/remarkd-editor/remarkd-editor.scss +12 -4
  262. package/src/components/report-table/index.ts +12 -0
  263. package/src/components/report-table/report-format.test.ts +86 -0
  264. package/src/components/report-table/report-format.ts +72 -0
  265. package/src/components/report-table/report-math.test.ts +74 -0
  266. package/src/components/report-table/report-math.ts +252 -0
  267. package/src/components/report-table/report-table.component.ts +681 -0
  268. package/src/components/report-table/report-table.scss +328 -0
  269. package/src/components/report-table/report-table.test.ts +702 -0
  270. package/src/components/reveal/reveal.component.ts +15 -1
  271. package/src/components/reveal/reveal.scss +5 -0
  272. package/src/components/schedule-builder/schedule-builder.component.ts +23 -8
  273. package/src/components/scroll-container/scroll-container.component.ts +19 -1
  274. package/src/components/scroll-container/scroll-container.scss +6 -1
  275. package/src/components/select/select.component.ts +52 -36
  276. package/src/components/select/select.scss +42 -25
  277. package/src/components/select/select.test.ts +14 -2
  278. package/src/components/settings-container/settings-container.scss +18 -5
  279. package/src/components/sidebar/sidebar.component.ts +8 -1
  280. package/src/components/sidebar/sidebar.scss +6 -1
  281. package/src/components/skeleton/skeleton.component.ts +3 -3
  282. package/src/components/skeleton/skeleton.scss +6 -6
  283. package/src/components/skeleton/skeleton.test.ts +9 -0
  284. package/src/components/slash-menu/slash-menu.scss +1 -1
  285. package/src/components/slideout/slideout.component.ts +20 -3
  286. package/src/components/slideout/slideout.scss +13 -6
  287. package/src/components/slideout/slideout.test.ts +19 -0
  288. package/src/components/split-button/split-button.component.ts +1 -1
  289. package/src/components/split-pane/split-pane.component.ts +71 -8
  290. package/src/components/split-pane/split-pane.scss +145 -41
  291. package/src/components/split-pane/split-pane.test.ts +23 -3
  292. package/src/components/stat/stat.component.ts +209 -14
  293. package/src/components/stat/stat.scss +305 -5
  294. package/src/components/stat/stat.test.ts +143 -2
  295. package/src/components/status-indicator/status-indicator.scss +22 -21
  296. package/src/components/stepper/stepper.scss +3 -3
  297. package/src/components/tab/tab.component.ts +1 -0
  298. package/src/components/table/table.component.ts +12 -1
  299. package/src/components/table/table.scss +21 -13
  300. package/src/components/table/table.test.ts +17 -0
  301. package/src/components/tabs/tabs.component.ts +150 -37
  302. package/src/components/tabs/tabs.scss +48 -7
  303. package/src/components/tabs/tabs.test.ts +169 -0
  304. package/src/components/textarea/textarea.component.ts +25 -3
  305. package/src/components/textarea/textarea.scss +10 -0
  306. package/src/components/textarea/textarea.test.ts +8 -4
  307. package/src/components/theme-editor/theme-editor.scss +0 -6
  308. package/src/components/tile/tile.component.ts +32 -2
  309. package/src/components/tile/tile.scss +40 -7
  310. package/src/components/tile/tile.test.ts +10 -0
  311. package/src/components/toggle/toggle.component.ts +23 -7
  312. package/src/components/toggle/toggle.scss +36 -29
  313. package/src/components/toggle/toggle.test.ts +17 -0
  314. package/src/components/tooltip/tooltip.component.ts +3 -0
  315. package/src/components/tooltip/tooltip.scss +11 -6
  316. package/src/components/tooltip/tooltip.test.ts +12 -0
  317. package/src/components/translation-field/index.ts +12 -0
  318. package/src/components/translation-field/translation-field.component.ts +55 -0
  319. package/src/components/translation-field/translation-field.scss +101 -0
  320. package/src/components/translation-fields/index.ts +12 -0
  321. package/src/components/translation-fields/translation-fields.component.ts +80 -0
  322. package/src/components/translation-fields/translation-fields.scss +60 -0
  323. package/src/components/translation-grid/index.ts +12 -0
  324. package/src/components/translation-grid/translation-grid.component.ts +157 -0
  325. package/src/components/translation-grid/translation-grid.scss +138 -0
  326. package/src/components/translation-grid/translation-grid.test.ts +28 -0
  327. package/src/components/vertical-stepper/vertical-stepper.component.ts +35 -9
  328. package/src/components/vertical-stepper/vertical-stepper.scss +110 -39
  329. package/src/components/vertical-stepper/vertical-stepper.test.ts +35 -0
  330. package/src/components/well/well.scss +2 -2
  331. package/src/events/events.ts +2 -0
  332. package/src/events/zn-chart-click.ts +13 -0
  333. package/src/events/zn-report-change.ts +7 -0
  334. package/src/form-control.scss +36 -3
  335. package/src/internal/anchored-popover.ts +175 -0
  336. package/src/internal/animate.ts +19 -5
  337. package/src/internal/cosmic-border.ts +15 -0
  338. package/src/internal/form.ts +1 -1
  339. package/src/internal/hover-intent.ts +15 -0
  340. package/src/internal/input-modality.ts +15 -0
  341. package/src/internal/pointer-intent.ts +37 -0
  342. package/src/internal/reorder.ts +625 -0
  343. package/src/internal/scroll.ts +51 -3
  344. package/src/internal/selection-card.ts +1 -1
  345. package/src/internal/tab-indicator.ts +92 -0
  346. package/src/internal/table-sort.ts +25 -0
  347. package/src/internal/table-tabs.ts +108 -0
  348. package/src/internal/test-drag.ts +35 -0
  349. package/src/internal/theme-openbyte.test.ts +126 -0
  350. package/src/internal/theme.ts +4 -4
  351. package/src/internal/zinc-element.ts +2 -2
  352. package/src/selection-card.scss +25 -0
  353. package/src/types/web-test-runner-commands.d.ts +1 -0
  354. package/src/utilities/color.test.ts +43 -0
  355. package/src/utilities/color.ts +189 -0
  356. package/src/zinc.ts +14 -0
  357. package/.github/workflows/eleventy_build.yml +0 -42
  358. package/scss/themes/_aura.scss +0 -114
@@ -0,0 +1,702 @@
1
+ import '../../../dist/zn.min.js';
2
+ import {expect, fixture, html, waitUntil} from '@open-wc/testing';
3
+ import type ZnReportTable from './report-table.component';
4
+
5
+ interface Call {url: string; init: RequestInit}
6
+
7
+ const json = (body: unknown) => new Response(JSON.stringify(body), {status: 200, headers: {'Content-Type': 'application/json'}});
8
+
9
+ function stubFetch(handler: (index: number) => Response | Promise<Response>) {
10
+ const calls: Call[] = [];
11
+ const original = window.fetch;
12
+ window.fetch = ((url: RequestInfo | URL, init?: RequestInit) => {
13
+ calls.push({url: String(url), init: init ?? {}});
14
+ return Promise.resolve(handler(calls.length - 1));
15
+ }) as typeof fetch;
16
+ return {calls, restore: () => (window.fetch = original)};
17
+ }
18
+
19
+ const report = (overrides: Record<string, unknown> = {}) => ({
20
+ dimension: {label: 'Publisher'},
21
+ groups: [
22
+ {
23
+ label: 'Difference', columns: [
24
+ {key: 'rev', label: 'Revenue', format: 'currency', style: 'breakeven', formula: '{d2.rev} - {d1.rev}'},
25
+ {key: 'ratio', label: 'Ratio', format: 'percent', formula: '{d2.acq} / {d2.clicks}'},
26
+ ],
27
+ },
28
+ {label: '2026-10-19', columns: [{key: 'd1Rev', label: 'Revenue', format: 'currency', value: 'd1.rev'}]},
29
+ ],
30
+ rows: [
31
+ {id: 'a', label: 'Alpha', uri: '#a', values: {d1: {rev: 10}, d2: {rev: 15, acq: 1, clicks: 10}}},
32
+ {id: 'b', label: 'Beta', values: {d1: {rev: 20}, d2: {rev: 5, acq: 9, clicks: 30}}},
33
+ ],
34
+ ...overrides,
35
+ });
36
+
37
+ async function load(body: unknown, markup = html`<zn-report-table data-uri="/report" locale="en-US"></zn-report-table>`) {
38
+ const stub = stubFetch(() => json(body));
39
+ try {
40
+ const el = await fixture<ZnReportTable>(markup);
41
+ await waitUntil(() => el.shadowRoot?.querySelector('tbody tr, zn-empty-state'));
42
+ return el;
43
+ } finally {
44
+ stub.restore();
45
+ }
46
+ }
47
+
48
+ const cells = (el: ZnReportTable, selector: string) =>
49
+ [...el.shadowRoot!.querySelectorAll<HTMLElement>(selector)];
50
+
51
+ describe('<zn-report-table>', () => {
52
+ it('posts the report state', async () => {
53
+ const stub = stubFetch(() => json(report()));
54
+ try {
55
+ await fixture(html`
56
+ <zn-report-table data-uri="/report" type="compare" group-by="affiliateFid" from="2026-10-19" to="2026-10-20">
57
+ <input slot="inputs" name="csrf" value="t">
58
+ </zn-report-table>`);
59
+ await waitUntil(() => stub.calls.length === 1);
60
+
61
+ const {url, init} = stub.calls[0];
62
+ expect(url).to.equal('/report');
63
+ expect(init.method).to.equal('POST');
64
+ expect((init.headers as Record<string, string>)['x-kx-fetch-style']).to.equal('zn-report-table');
65
+ expect(JSON.parse(init.body as string)).to.deep.equal({
66
+ type: 'compare', groupBy: 'affiliateFid', from: '2026-10-19', to: '2026-10-20', filter: '', csrf: 't',
67
+ });
68
+ } finally {
69
+ stub.restore();
70
+ }
71
+ });
72
+
73
+ it('renders group headings over their columns', async () => {
74
+ const el = await load(report());
75
+
76
+ const groups = cells(el, 'thead tr.report__groups th');
77
+ expect(groups.map(th => th.textContent!.trim())).to.deep.equal(['', 'Difference', '2026-10-19']);
78
+ expect(groups[1].getAttribute('colspan')).to.equal('2');
79
+ expect(groups[2].getAttribute('colspan')).to.equal('1');
80
+
81
+ const names = cells(el, 'thead tr:not(.report__groups) th');
82
+ expect(names[0].classList.contains('report__dim')).to.be.true;
83
+ expect(names[0].textContent!.trim()).to.equal('Publisher');
84
+ expect(names.map(th => th.classList.contains('report__sep'))).to.deep.equal([false, true, false, true]);
85
+ const body = cells(el, 'tbody tr:first-child td');
86
+ expect(body.map(td => td.classList.contains('report__sep'))).to.deep.equal([false, true, false, true]);
87
+ const foot = cells(el, 'tfoot tr td');
88
+ expect(foot.map(td => td.classList.contains('report__sep'))).to.deep.equal([false, true, false, true]);
89
+ });
90
+
91
+ it('hides a duplicate heading', async () => {
92
+ const el = await load(report({
93
+ groups: [{label: 'Leads', columns: [{key: 'leads', label: 'Leads', value: 'd1.rev'}]}],
94
+ }));
95
+
96
+ const names = cells(el, 'thead tr:not(.report__groups) th');
97
+ expect(names[1].textContent!.trim()).to.equal('');
98
+ });
99
+
100
+ it('links the first column', async () => {
101
+ const el = await load(report());
102
+
103
+ const dims = cells(el, 'tbody td.report__dim');
104
+ expect(dims[0].querySelector('a')!.getAttribute('href')).to.equal('#a');
105
+ expect(dims[0].textContent!.trim()).to.equal('Alpha');
106
+ expect(dims[1].querySelector('a')).to.not.exist;
107
+ });
108
+
109
+ it('links cells and passes targets as data-target', async () => {
110
+ const el = await load(report({
111
+ rows: [{id: 'a', label: 'Alpha', uri: '/a', target: 'slide', values: {d1: {rev: 10}}, links: {d1Rev: {uri: '/a/rev', target: 'slide'}}}],
112
+ }));
113
+
114
+ const dim = cells(el, 'tbody td.report__dim a')[0];
115
+ expect(dim.hasAttribute('target')).to.be.false;
116
+ expect(dim.getAttribute('data-target')).to.equal('slide');
117
+ const link = cells(el, 'tbody td a[href="/a/rev"]')[0];
118
+ expect(link.getAttribute('data-target')).to.equal('slide');
119
+ expect(link.textContent).to.equal('$10.00');
120
+ expect(cells(el, 'tbody td a')).to.have.length(2);
121
+ });
122
+
123
+ it('only shows tick boxes when selectable', async () => {
124
+ const el = await load(report());
125
+
126
+ expect(cells(el, 'zn-checkbox')).to.have.length(0);
127
+ expect(cells(el, 'tbody tr[aria-selected]')).to.have.length(0);
128
+ });
129
+
130
+ it('hides columns that are empty in every row, and their groups', async () => {
131
+ const el = await load(report({
132
+ groups: [
133
+ {label: 'Shown', columns: [{key: 'a', label: 'A', value: 'a'}, {key: 'zero', label: 'Zero', value: 'z'}]},
134
+ {label: 'Empty', columns: [{key: 'b', label: 'B', value: 'b'}]},
135
+ ],
136
+ rows: [{id: 'r', label: 'Row', values: {a: 1, z: 0, b: 0}}],
137
+ }), html`<zn-report-table data-uri="/report" locale="en-US" hide-empty-columns></zn-report-table>`);
138
+
139
+ expect(cells(el, 'thead tr:not(.report__groups) th').map(th => th.textContent!.trim())).to.deep.equal(['Publisher', 'A']);
140
+ expect(cells(el, '.report__glabel').map(label => label.textContent!.trim())).to.deep.equal(['Shown']);
141
+ });
142
+
143
+ it('shades heatmap cells against their column maximum', async () => {
144
+ const el = await load(report({
145
+ groups: [{label: 'Counts', columns: [{key: 'n', label: 'N', value: 'n'}]}],
146
+ rows: [
147
+ {id: 'a', label: 'A', values: {n: 4}},
148
+ {id: 'b', label: 'B', values: {n: 2}},
149
+ {id: 'c', label: 'C', values: {n: 0}},
150
+ ],
151
+ }), html`<zn-report-table data-uri="/report" locale="en-US" heatmap></zn-report-table>`);
152
+
153
+ const heat = cells(el, 'tbody td.report__num').map(td => td.style.getPropertyValue('--_heat'));
154
+ expect(heat).to.deep.equal(['1.000', '0.500', '']);
155
+ });
156
+
157
+ it('computes totals from formulas', async () => {
158
+ const el = await load(report());
159
+
160
+ const total = cells(el, 'tfoot tr.report__total td').map(td => td.textContent!.trim());
161
+ expect(total).to.deep.equal(['Total', '($10.00)', '25.00%', '$30.00']);
162
+ });
163
+
164
+ it('uses server totals', async () => {
165
+ const el = await load(report({totals: {rev: 99}}));
166
+
167
+ const total = cells(el, 'tfoot tr.report__total td').map(td => td.textContent!.trim());
168
+ expect(total).to.deep.equal(['Total', '$99.00', '–', '–']);
169
+ });
170
+
171
+ it('colours breakeven columns only', async () => {
172
+ const el = await load(report());
173
+
174
+ const tones = cells(el, 'tbody tr').map(tr => [...tr.querySelectorAll('td')].slice(1).map(td =>
175
+ td.classList.contains('report__pos') ? 'pos' : td.classList.contains('report__neg') ? 'neg' : ''));
176
+ expect(tones).to.deep.equal([['pos', '', ''], ['neg', '', '']]);
177
+ });
178
+
179
+ it('shows the response error', async () => {
180
+ const el = await load(report({error: {text: 'Results limited', level: 'warning'}}));
181
+
182
+ const alert = el.shadowRoot!.querySelector('zn-panel zn-alert')!;
183
+ expect(alert).to.exist;
184
+ expect(alert.textContent).to.contain('Results limited');
185
+ expect(alert.getAttribute('level')).to.equal('warning');
186
+ });
187
+
188
+ it('shows an empty state inside the panel', async () => {
189
+ const el = await load(report({rows: []}), html`<zn-report-table data-uri="/report" caption="Day Compare"></zn-report-table>`);
190
+
191
+ expect(el.shadowRoot!.querySelector('zn-panel zn-empty-state')).to.exist;
192
+ expect(el.shadowRoot!.querySelector('zn-panel')!.getAttribute('caption')).to.equal('Day Compare');
193
+ });
194
+
195
+ it('shows the skeleton while refetching an empty report', async () => {
196
+ let release!: (response: Response) => void;
197
+ const stub = stubFetch(index => index === 0
198
+ ? json(report({rows: []}))
199
+ : new Promise<Response>(resolve => (release = resolve)));
200
+ try {
201
+ const el = await fixture<ZnReportTable>(html`<zn-report-table data-uri="/report" from="2026-10-01"></zn-report-table>`);
202
+ await waitUntil(() => el.shadowRoot?.querySelector('zn-empty-state'));
203
+
204
+ el.from = '2025-06-01';
205
+ await waitUntil(() => el.shadowRoot!.querySelector('.report__skeleton'));
206
+ expect(el.shadowRoot!.querySelector('zn-empty-state')).to.not.exist;
207
+
208
+ release(json(report({rows: []})));
209
+ await waitUntil(() => el.shadowRoot!.querySelector('zn-empty-state'));
210
+ } finally {
211
+ stub.restore();
212
+ }
213
+ });
214
+
215
+ it('tolerates nil and empty-array payloads', async () => {
216
+ const empty = await load({groups: null, rows: null});
217
+ expect(empty.shadowRoot!.querySelector('zn-panel zn-empty-state')).to.exist;
218
+
219
+ const el = await load(report({
220
+ totals: [],
221
+ rows: [{id: 'a', label: 'Alpha', values: []}, {id: 'b', label: 'Beta', values: {d1: {rev: 4}, d2: {rev: 6}}}],
222
+ }));
223
+ const total = cells(el, 'tfoot tr.report__total td').map(td => td.textContent!.trim());
224
+ expect(total).to.deep.equal(['Total', '$2.00', '0.00%', '$4.00']);
225
+ });
226
+
227
+ it('ignores a superseded response', async () => {
228
+ let releaseFirst!: (response: Response) => void;
229
+ const stub = stubFetch(index => index === 0
230
+ ? new Promise<Response>(resolve => (releaseFirst = resolve))
231
+ : json(report({rows: [{id: 's', label: 'Second', values: {}}]})));
232
+ try {
233
+ const el = await fixture<ZnReportTable>(html`<zn-report-table data-uri="/report" group-by="a"></zn-report-table>`);
234
+ await waitUntil(() => stub.calls.length === 1);
235
+
236
+ el.groupBy = 'b';
237
+ await waitUntil(() => el.shadowRoot!.querySelector('tbody td.report__dim'));
238
+ releaseFirst(json(report({rows: [{id: 'f', label: 'First', values: {}}]})));
239
+ await new Promise(resolve => setTimeout(resolve, 50));
240
+ await el.updateComplete;
241
+
242
+ expect(cells(el, 'tbody td.report__dim').map(td => td.textContent!.trim())).to.deep.equal(['Second']);
243
+ expect((JSON.parse(stub.calls[1].init.body as string) as {groupBy: string}).groupBy).to.equal('b');
244
+ expect(stub.calls[0].init.signal!.aborted).to.be.true;
245
+ } finally {
246
+ stub.restore();
247
+ }
248
+ });
249
+
250
+ describe('header controls', () => {
251
+ const click = (target: Element) => target.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
252
+ const bodyOf = (call: Call) => JSON.parse(call.init.body as string) as Record<string, string>;
253
+ const labels = (el: ZnReportTable) => cells(el, 'tbody td.report__dim').map(td => td.textContent!.trim());
254
+
255
+ async function mount(markup: ReturnType<typeof html>, body: (index: number) => unknown = () => report()) {
256
+ const stub = stubFetch(index => json(body(index)));
257
+ const el = await fixture<ZnReportTable>(markup);
258
+ await waitUntil(() => el.shadowRoot?.querySelector('tbody tr, zn-empty-state'));
259
+ return {el, stub};
260
+ }
261
+
262
+ const sixRows = report({
263
+ totals: {rev: 99},
264
+ rows: ['Alpha', 'Alpine', 'Beta', 'Gamma', 'Delta', 'Epsilon'].map((label, i) => ({
265
+ id: label, label, values: {d1: {rev: 0}, d2: {rev: i + 1, acq: 1, clicks: 10}},
266
+ })),
267
+ });
268
+
269
+ it('highlights ticked rows, ticks all shown, and clears on group by change', async () => {
270
+ const {el, stub} = await mount(html`
271
+ <zn-report-table data-uri="/report" group-by="a" selectable
272
+ group-bys='[{"value":"a","label":"A"},{"value":"b","label":"B"}]'></zn-report-table>`);
273
+ try {
274
+ const root = el.shadowRoot!;
275
+ const selected = () => cells(el, 'tbody tr').map(row => row.getAttribute('aria-selected'));
276
+
277
+ root.querySelector('tbody zn-checkbox')!.shadowRoot!.querySelector<HTMLInputElement>('input')!.click();
278
+ await el.updateComplete;
279
+ expect(selected()).to.deep.equal(['true', 'false']);
280
+
281
+ const all = root.querySelector<HTMLElement & {indeterminate: boolean}>('thead zn-checkbox')!;
282
+ expect(all.indeterminate).to.be.true;
283
+ all.shadowRoot!.querySelector<HTMLInputElement>('input')!.click();
284
+ await el.updateComplete;
285
+ expect(selected()).to.deep.equal(['true', 'true']);
286
+ expect(root.querySelector('thead tr:not(.report__groups) th.report__dim')!.getAttribute('aria-sort')).to.equal('none');
287
+
288
+ click(root.querySelector('.report__groupby')!);
289
+ await el.updateComplete;
290
+ click(cells(el, '.report__gpop .report__gopt')[1]);
291
+ await el.updateComplete;
292
+ expect(selected()).to.deep.equal(['false', 'false']);
293
+ } finally {
294
+ stub.restore();
295
+ }
296
+ });
297
+
298
+ it('search narrows rows and switches to computed totals', async () => {
299
+ const {el, stub} = await mount(html`<zn-report-table data-uri="/report" locale="en-US" caption="Day Compare"></zn-report-table>`, () => sixRows);
300
+ try {
301
+ const panel = el.shadowRoot!.querySelector('zn-panel')!;
302
+ expect(panel.getAttribute('description')).to.equal('6 rows');
303
+ expect(cells(el, 'tfoot td')[1].textContent!.trim()).to.equal('$99.00');
304
+
305
+ const search = el.shadowRoot!.querySelector('zn-data-table-search.report__search')!;
306
+ expect(search.getAttribute('placeholder')).to.equal('Search publisher...');
307
+ const leaked: Event[] = [];
308
+ el.addEventListener('zn-search-change', e => leaked.push(e));
309
+ await (search as HTMLElement & {updateComplete: Promise<unknown>}).updateComplete;
310
+ const input = search.shadowRoot!.querySelector<HTMLElement & {value: string}>('zn-input')!;
311
+ input.value = 'alp';
312
+ input.dispatchEvent(new CustomEvent('zn-input', {bubbles: true, composed: true}));
313
+ await waitUntil(() => labels(el).length === 2);
314
+
315
+ expect(labels(el)).to.deep.equal(['Alpha', 'Alpine']);
316
+ expect(leaked).to.have.length(0);
317
+ expect(panel.getAttribute('description')).to.equal('2 of 6 rows');
318
+ expect(cells(el, 'tfoot td')[1].textContent!.trim()).to.equal('$3.00');
319
+ expect(stub.calls.length).to.equal(1);
320
+ } finally {
321
+ stub.restore();
322
+ }
323
+ });
324
+
325
+ it('sorts columns descending first, then ascending, then server order', async () => {
326
+ const rows = [
327
+ {id: 'a', label: 'Sub ID 10', values: {d1: {rev: 0}, d2: {rev: 5}}},
328
+ {id: 'b', label: 'Sub ID 9', values: {d1: {rev: 0}, d2: {rev: 20}}},
329
+ {id: 'c', label: 'Sub ID 100', values: {d1: {rev: 0}, d2: {rev: '12.5'}}},
330
+ ];
331
+ const {el, stub} = await mount(html`<zn-report-table data-uri="/report" locale="en-US"></zn-report-table>`, () => report({rows}));
332
+ try {
333
+ const head = (index: number) => cells(el, 'thead tr:not(.report__groups) th')[index];
334
+ const press = async (index: number) => {
335
+ click(head(index));
336
+ await el.updateComplete;
337
+ };
338
+
339
+ expect(head(1).getAttribute('aria-sort')).to.equal('none');
340
+ await press(1);
341
+ expect(head(1).getAttribute('aria-sort')).to.equal('descending');
342
+ expect(labels(el)).to.deep.equal(['Sub ID 9', 'Sub ID 100', 'Sub ID 10']);
343
+ await press(1);
344
+ expect(head(1).getAttribute('aria-sort')).to.equal('ascending');
345
+ expect(labels(el)).to.deep.equal(['Sub ID 10', 'Sub ID 100', 'Sub ID 9']);
346
+ await press(1);
347
+ expect(head(1).getAttribute('aria-sort')).to.equal('none');
348
+ expect(labels(el)).to.deep.equal(['Sub ID 10', 'Sub ID 9', 'Sub ID 100']);
349
+
350
+ await press(0);
351
+ expect(labels(el)).to.deep.equal(['Sub ID 100', 'Sub ID 10', 'Sub ID 9']);
352
+ head(0).dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true}));
353
+ await el.updateComplete;
354
+ expect(head(0).getAttribute('aria-sort')).to.equal('ascending');
355
+ expect(labels(el)).to.deep.equal(['Sub ID 9', 'Sub ID 10', 'Sub ID 100']);
356
+ expect(cells(el, 'tfoot td')[0].textContent!.trim()).to.equal('Total');
357
+ expect(stub.calls.length).to.equal(1);
358
+ } finally {
359
+ stub.restore();
360
+ }
361
+ });
362
+
363
+ it('type tabs only appear with two or more types and refetch on select', async () => {
364
+ const single = await mount(html`<zn-report-table data-uri="/report" types='[{"value":"compare","label":"Day Compare"}]'></zn-report-table>`);
365
+ try {
366
+ expect(single.el.shadowRoot!.querySelector('.table__tabs')).to.not.exist;
367
+ expect(bodyOf(single.stub.calls[0]).type).to.equal('compare');
368
+ } finally {
369
+ single.stub.restore();
370
+ }
371
+
372
+ const {el, stub} = await mount(html`
373
+ <zn-report-table data-uri="/report"
374
+ types='[{"value":"compare","label":"Day Compare"},{"value":"summary","label":"Summary"}]'></zn-report-table>`);
375
+ try {
376
+ const tabs = cells(el, '.table__tab');
377
+ expect(tabs.map(tab => tab.textContent!.trim())).to.deep.equal(['Day Compare', 'Summary']);
378
+ const changes: Event[] = [];
379
+ el.addEventListener('zn-report-change', e => changes.push(e));
380
+ click(tabs[0]);
381
+ await el.updateComplete;
382
+ expect(changes).to.have.length(0);
383
+
384
+ click(tabs[1]);
385
+ await waitUntil(() => stub.calls.length === 2);
386
+ expect(bodyOf(stub.calls[1]).type).to.equal('summary');
387
+ expect(el.getAttribute('type')).to.equal('summary');
388
+ } finally {
389
+ stub.restore();
390
+ }
391
+ });
392
+
393
+ it('group by menu lists options under category headings and refetches', async () => {
394
+ const {el, stub} = await mount(html`
395
+ <zn-report-table data-uri="/report" group-by="affiliateFid"
396
+ group-bys='[{"value":"affiliateFid","label":"Publisher","category":"Publisher"},{"value":"campaignFid","label":"Campaign","category":"Campaign"},{"value":"country","label":"Country","category":"Geo"}]'></zn-report-table>`);
397
+ try {
398
+ const root = el.shadowRoot!;
399
+ const trigger = root.querySelector('.report__groupby')!;
400
+ expect(trigger.textContent!.replace(/\s+/g, ' ').trim()).to.equal('Group by: Publisher');
401
+
402
+ click(trigger);
403
+ await el.updateComplete;
404
+ expect(cells(el, '.report__gpop .report__gcat__head').map(h => h.textContent!.trim())).to.deep.equal(['Publisher', 'Campaign', 'Geo']);
405
+ const options = cells(el, '.report__gpop .report__gopt');
406
+ expect(options.map(o => o.textContent!.trim())).to.deep.equal(['Publisher', 'Campaign', 'Country']);
407
+ expect(options[0].getAttribute('aria-current')).to.equal('true');
408
+
409
+ click(options[2]);
410
+ await waitUntil(() => stub.calls.length === 2);
411
+ expect(bodyOf(stub.calls[1]).groupBy).to.equal('country');
412
+ expect(el.getAttribute('group-by')).to.equal('country');
413
+ await el.updateComplete;
414
+ expect(root.querySelector('.report__gpop')).to.not.exist;
415
+ } finally {
416
+ stub.restore();
417
+ }
418
+ });
419
+
420
+ it('moves focus into the group by menu and back to its trigger', async () => {
421
+ const {el, stub} = await mount(html`
422
+ <zn-report-table data-uri="/report" group-by="b" group-bys='[{"value":"a","label":"A"},{"value":"b","label":"B"}]'></zn-report-table>`);
423
+ try {
424
+ const root = el.shadowRoot!;
425
+ const trigger = root.querySelector('.report__groupby')!;
426
+ click(trigger);
427
+ await waitUntil(() => root.activeElement?.classList.contains('report__gopt'));
428
+ expect(root.activeElement!.textContent!.trim()).to.equal('B');
429
+
430
+ document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', bubbles: true}));
431
+ await el.updateComplete;
432
+ expect(root.querySelector('.report__gpop')).to.not.exist;
433
+ expect(root.activeElement).to.equal(trigger);
434
+
435
+ click(trigger);
436
+ await waitUntil(() => root.activeElement?.classList.contains('report__gopt'));
437
+ click(cells(el, '.report__gopt')[0]);
438
+ await el.updateComplete;
439
+ expect(root.activeElement).to.equal(trigger);
440
+ } finally {
441
+ stub.restore();
442
+ }
443
+ });
444
+
445
+ it('lists group bys without categories as one list', async () => {
446
+ const {el, stub} = await mount(html`
447
+ <zn-report-table data-uri="/report" group-bys='[{"value":"a","label":"A"},{"value":"b","label":"B"}]'></zn-report-table>`);
448
+ try {
449
+ click(el.shadowRoot!.querySelector('.report__groupby')!);
450
+ await el.updateComplete;
451
+ expect(cells(el, '.report__gpop .report__gcat__head')).to.have.length(0);
452
+ expect(cells(el, '.report__gpop .report__gopt')).to.have.length(2);
453
+ } finally {
454
+ stub.restore();
455
+ }
456
+ });
457
+
458
+ it('date range apply refetches with new from/to', async () => {
459
+ const {el, stub} = await mount(html`<zn-report-table data-uri="/report" from="2026-10-19" to="2026-10-20" max-date="2026-10-20"></zn-report-table>`);
460
+ try {
461
+ const range = el.shadowRoot!.querySelector<HTMLElement & {from: string; to: string}>('zn-date-range')!;
462
+ expect(range.getAttribute('max')).to.equal('2026-10-20');
463
+ range.from = '2026-10-01';
464
+ range.to = '2026-10-07';
465
+ range.dispatchEvent(new CustomEvent('zn-change', {bubbles: true, composed: true, detail: {from: '2026-10-01', to: '2026-10-07'}}));
466
+ await waitUntil(() => stub.calls.length === 2);
467
+ expect(bodyOf(stub.calls[1])).to.include({from: '2026-10-01', to: '2026-10-07'});
468
+ expect(el.getAttribute('from')).to.equal('2026-10-01');
469
+ } finally {
470
+ stub.restore();
471
+ }
472
+ });
473
+
474
+ it('date range shows all eight quick ranges on screen, and its note', async () => {
475
+ const {el, stub} = await mount(html`<zn-report-table data-uri="/report" from="2026-09-20" to="2026-10-20"
476
+ date-note="Day Compare sets the first day against the last."></zn-report-table>`);
477
+ try {
478
+ el.style.cssText = `display: block; margin-top: ${window.innerHeight - 160}px`;
479
+ const range = el.shadowRoot!.querySelector<HTMLElement & {updateComplete: Promise<boolean>}>('zn-date-range')!;
480
+ await range.updateComplete;
481
+ click(range.shadowRoot!.querySelector('.date-range__trigger')!);
482
+ await range.updateComplete;
483
+ await range.updateComplete;
484
+
485
+ const presets = [...range.shadowRoot!.querySelectorAll<HTMLElement>('.date-range__preset')];
486
+ expect(presets.map(p => p.textContent!.trim())).to.deep.equal([
487
+ 'Today', 'Yesterday', 'Last 7 days', 'Last 14 days', 'Last 30 days', 'This month', 'Last month', 'Last 90 days'
488
+ ]);
489
+ presets.forEach(p => expect(p.getBoundingClientRect().bottom).to.be.at.most(window.innerHeight));
490
+ expect(range.shadowRoot!.querySelector('.date-range__note')!.textContent).to.equal('Day Compare sets the first day against the last.');
491
+ } finally {
492
+ stub.restore();
493
+ }
494
+ });
495
+
496
+ it('funnel sends base64 filter rules', async () => {
497
+ const {el, stub} = await mount(html`
498
+ <zn-report-table data-uri="/report" filter-fields='[{"id":"status","name":"Status","options":{"active":"Active"}}]'></zn-report-table>`);
499
+ try {
500
+ type FilterEl = HTMLElement & {updateComplete: Promise<unknown>};
501
+ const funnel = el.shadowRoot!.querySelector<FilterEl>('.report__actions zn-data-table-filter')!;
502
+ const root = funnel.shadowRoot!;
503
+ const choose = (select: Element, value: string) => {
504
+ (select as HTMLElement & {value: string}).value = value;
505
+ select.dispatchEvent(new CustomEvent('zn-change', {bubbles: true, composed: true}));
506
+ };
507
+ click(root.querySelector('.filter__toggle')!);
508
+ await funnel.updateComplete;
509
+ choose(root.querySelector('.filter__rule zn-select[aria-label="Filter field"]')!, 'status');
510
+ await funnel.updateComplete;
511
+ await waitUntil(() => root.querySelector('.filter__rule zn-select[aria-label="Filter value"]'));
512
+ choose(root.querySelector('.filter__rule zn-select[aria-label="Filter value"]')!, 'active');
513
+ await funnel.updateComplete;
514
+ click(root.querySelector('.filter__pop__foot zn-button[color="primary"]')!);
515
+
516
+ const expected = btoa(JSON.stringify([{key: 'status', comparator: 'eq', value: 'active'}]));
517
+ await waitUntil(() => stub.calls.length === 2);
518
+ expect(bodyOf(stub.calls[1]).filter).to.equal(expected);
519
+ expect(el.filter).to.equal(expected);
520
+ await el.updateComplete;
521
+ const chips = el.shadowRoot!.querySelector<FilterEl>('.table__chips')!;
522
+ await chips.updateComplete;
523
+ expect(chips.shadowRoot!.querySelector('.filter__chip__text')!.textContent!.trim()).to.equal('Status is Active');
524
+ } finally {
525
+ stub.restore();
526
+ }
527
+ });
528
+
529
+ it('emits zn-report-change before fetching', async () => {
530
+ const {el, stub} = await mount(html`
531
+ <zn-report-table data-uri="/report" type="compare" group-by="a" from="2026-10-19" to="2026-10-20"
532
+ group-bys='[{"value":"a","label":"A"},{"value":"b","label":"B"}]'></zn-report-table>`);
533
+ try {
534
+ const seen: {detail: unknown; calls: number}[] = [];
535
+ el.addEventListener('zn-report-change', e => seen.push({detail: e.detail, calls: stub.calls.length}));
536
+ click(el.shadowRoot!.querySelector('.report__groupby')!);
537
+ await el.updateComplete;
538
+ click(cells(el, '.report__gopt')[1]);
539
+ await waitUntil(() => stub.calls.length === 2);
540
+
541
+ expect(seen).to.deep.equal([{detail: {type: 'compare', groupBy: 'b', from: '2026-10-19', to: '2026-10-20', filter: ''}, calls: 1}]);
542
+ } finally {
543
+ stub.restore();
544
+ }
545
+ });
546
+
547
+ it('response overrides group-bys, filter fields and adopts its groupBy', async () => {
548
+ const {el, stub} = await mount(html`
549
+ <zn-report-table data-uri="/report" group-by="affiliateFid" group-bys='[{"value":"affiliateFid","label":"Publisher"}]'></zn-report-table>`,
550
+ () => report({
551
+ groupBy: 'country',
552
+ groupBys: [{value: 'country', label: 'Country'}, {value: 'city', label: 'City'}],
553
+ filterFields: [{id: 'device', name: 'Device'}],
554
+ }));
555
+ try {
556
+ await el.updateComplete;
557
+ expect(el.groupBy).to.equal('country');
558
+ const root = el.shadowRoot!;
559
+ expect(root.querySelector('.report__groupby')!.textContent!.replace(/\s+/g, ' ').trim()).to.equal('Group by: Country');
560
+ expect(root.querySelector('.report__actions zn-data-table-filter')).to.exist;
561
+ click(root.querySelector('.report__groupby')!);
562
+ await el.updateComplete;
563
+ expect(cells(el, '.report__gopt').map(o => o.textContent!.trim())).to.deep.equal(['Country', 'City']);
564
+ await new Promise(resolve => setTimeout(resolve, 50));
565
+ expect(stub.calls.length).to.equal(1);
566
+ } finally {
567
+ stub.restore();
568
+ }
569
+ });
570
+
571
+ it('fetches an adopted group by again once another has been fetched', async () => {
572
+ const {el, stub} = await mount(html`
573
+ <zn-report-table data-uri="/report" group-by="a"
574
+ group-bys='[{"value":"a","label":"A"},{"value":"b","label":"B"}]'></zn-report-table>`,
575
+ index => report(index === 0 ? {groupBy: 'b'} : {}));
576
+ try {
577
+ await el.updateComplete;
578
+ expect(el.groupBy).to.equal('b');
579
+
580
+ el.groupBy = 'a';
581
+ await waitUntil(() => stub.calls.length === 2);
582
+ el.groupBy = 'b';
583
+ await waitUntil(() => stub.calls.length === 3);
584
+ expect(bodyOf(stub.calls[2]).groupBy).to.equal('b');
585
+ } finally {
586
+ stub.restore();
587
+ }
588
+ });
589
+
590
+ it('keeps group labels beside the frozen column', async () => {
591
+ const {el, stub} = await mount(html`<zn-report-table data-uri="/report"></zn-report-table>`);
592
+ try {
593
+ const label = el.shadowRoot!.querySelector<HTMLElement>('.report__groups th.report__sep .report__glabel')!;
594
+ expect(getComputedStyle(label).position).to.equal('sticky');
595
+ const dim = el.shadowRoot!.querySelector<HTMLElement>('.report__groups th.report__dim')!;
596
+ await waitUntil(() => el.style.getPropertyValue('--_dim-width') === `${dim.offsetWidth}px`);
597
+ } finally {
598
+ stub.restore();
599
+ }
600
+ });
601
+
602
+ it('sharable reads state from and writes it to the URL', async () => {
603
+ const original = window.location.href;
604
+ window.history.replaceState(window.history.state, '', `${window.location.pathname}?type=summary&groupBy=country&from=2026-10-01&to=2026-10-07`);
605
+ let stub: ReturnType<typeof stubFetch> | undefined;
606
+ try {
607
+ const mounted = await mount(html`
608
+ <zn-report-table data-uri="/report" sharable
609
+ types='[{"value":"compare","label":"Day Compare"},{"value":"summary","label":"Summary"}]'
610
+ group-bys='[{"value":"affiliateFid","label":"Publisher"},{"value":"country","label":"Country"},{"value":"city","label":"City"}]'></zn-report-table>`);
611
+ stub = mounted.stub;
612
+ const el = mounted.el;
613
+ expect(bodyOf(stub.calls[0])).to.include({type: 'summary', groupBy: 'country', from: '2026-10-01', to: '2026-10-07'});
614
+
615
+ click(el.shadowRoot!.querySelector('.report__groupby')!);
616
+ await el.updateComplete;
617
+ click(cells(el, '.report__gopt')[2]);
618
+ await el.updateComplete;
619
+
620
+ const params = new URLSearchParams(window.location.search);
621
+ expect(params.get('groupBy')).to.equal('city');
622
+ expect(params.get('type')).to.equal('summary');
623
+ expect(params.get('from')).to.equal('2026-10-01');
624
+ } finally {
625
+ stub?.restore();
626
+ window.history.replaceState(window.history.state, '', original);
627
+ }
628
+ });
629
+
630
+ it('opening one popover closes the others', async () => {
631
+ const {el, stub} = await mount(html`
632
+ <zn-report-table data-uri="/report" filter-fields='[{"id":"status","name":"Status"}]'
633
+ group-bys='[{"value":"a","label":"A"},{"value":"b","label":"B"}]'></zn-report-table>`);
634
+ try {
635
+ type Child = HTMLElement & {updateComplete: Promise<unknown>};
636
+ const root = el.shadowRoot!;
637
+ const funnel = root.querySelector<Child>('.report__actions zn-data-table-filter')!;
638
+ const range = root.querySelector<Child>('zn-date-range')!;
639
+
640
+ click(range.shadowRoot!.querySelector('.date-range__trigger')!);
641
+ await range.updateComplete;
642
+ expect(range.shadowRoot!.querySelector('.date-range__pop')).to.exist;
643
+
644
+ click(funnel.shadowRoot!.querySelector('.filter__toggle')!);
645
+ await funnel.updateComplete;
646
+ await range.updateComplete;
647
+ expect(funnel.shadowRoot!.querySelector('.filter__pop')).to.exist;
648
+ expect(range.shadowRoot!.querySelector('.date-range__pop')).to.not.exist;
649
+
650
+ click(root.querySelector('.report__groupby')!);
651
+ await el.updateComplete;
652
+ await funnel.updateComplete;
653
+ expect(root.querySelector('.report__gpop')).to.exist;
654
+ expect(funnel.shadowRoot!.querySelector('.filter__pop')).to.not.exist;
655
+ } finally {
656
+ stub.restore();
657
+ }
658
+ });
659
+
660
+ it('popover triggers expose their state on the inner button', async () => {
661
+ const {el, stub} = await mount(html`
662
+ <zn-report-table data-uri="/report" filter-fields='[{"id":"status","name":"Status"}]'
663
+ group-bys='[{"value":"a","label":"A"},{"value":"b","label":"B"}]'></zn-report-table>`);
664
+ try {
665
+ type Child = HTMLElement & {updateComplete: Promise<unknown>};
666
+ const root = el.shadowRoot!;
667
+ const funnel = root.querySelector<Child>('.report__actions zn-data-table-filter')!;
668
+ const range = root.querySelector<Child>('zn-date-range')!;
669
+ const triggers = [
670
+ root.querySelector<Child>('.report__groupby')!,
671
+ funnel.shadowRoot!.querySelector<Child>('.filter__toggle')!,
672
+ range.shadowRoot!.querySelector<Child>('.date-range__trigger')!,
673
+ ];
674
+ await Promise.all(triggers.map(trigger => trigger.updateComplete));
675
+ for (const trigger of triggers) {
676
+ const inner = trigger.shadowRoot!.querySelector('button')!;
677
+ expect(inner.getAttribute('aria-haspopup')).to.equal('dialog');
678
+ expect(inner.getAttribute('aria-expanded')).to.equal('false');
679
+ }
680
+
681
+ click(triggers[0]);
682
+ await el.updateComplete;
683
+ await triggers[0].updateComplete;
684
+ expect(triggers[0].shadowRoot!.querySelector('button')!.getAttribute('aria-expanded')).to.equal('true');
685
+ } finally {
686
+ stub.restore();
687
+ }
688
+ });
689
+ });
690
+
691
+ it('renders the other columns when one formula is invalid', async () => {
692
+ const el = await load(report({
693
+ groups: [{label: 'G', columns: [
694
+ {key: 'bad', label: 'Bad', format: 'number', formula: '{d1.rev} +'},
695
+ {key: 'rev', label: 'Revenue', format: 'currency', value: 'd1.rev'},
696
+ ]}],
697
+ }));
698
+
699
+ const first = cells(el, 'tbody tr:first-child td');
700
+ expect(first.map(td => td.textContent!.trim())).to.deep.equal(['Alpha', '0', '$10.00']);
701
+ });
702
+ });