@kubex/zinc 1.1.185 → 2.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 (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 +10319 -2666
  6. package/dist/vscode.html-custom-data.json +1012 -57
  7. package/dist/web-types.json +2097 -145
  8. package/dist/zn.d.ts +2294 -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 +136 -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 +632 -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
@@ -77,12 +77,21 @@ export default class ZnRadioGroup extends ZincElement implements ZincFormControl
77
77
  /** Squares off the corners of every contained radio in the group, which are rounded by default. */
78
78
  @property({type: Boolean, reflect: true}) square = false;
79
79
 
80
+ /** Centres the text of every contained radio in the group. */
81
+ @property({type: Boolean, reflect: true}) centered = false;
82
+
80
83
  /**
81
84
  * Lays horizontal contained radios out as a wrapping row instead of an equal-width grid, so each one sizes from
82
85
  * `--zn-radio-group-column-width` (set it to `auto` to size from content) and wraps onto a new line when needed.
83
86
  */
84
87
  @property({type: Boolean, reflect: true}) wrap = false;
85
88
 
89
+ /**
90
+ * Lays the radios out as a segmented scale, in document order, filling every segment up to the checked one. Suits
91
+ * ordered answers such as 1 to 10, and unlike a range input it has no value until one is picked.
92
+ */
93
+ @property({type: Boolean, reflect: true}) scale = false;
94
+
86
95
  /**
87
96
  * By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
88
97
  * to place the form control outside a form and associate it with the form that has this `id`. The form must be in
@@ -151,14 +160,23 @@ export default class ZnRadioGroup extends ZincElement implements ZincFormControl
151
160
  }
152
161
 
153
162
  private handleKeyDown(event: KeyboardEvent) {
154
- if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(event.key)) {
163
+ if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' ', 'Enter'].includes(event.key)) {
164
+ return;
165
+ }
166
+
167
+ // Keys typed into controls inside a radio's selected content belong to those controls
168
+ if (event.composedPath().some(el => el instanceof HTMLElement && el.slot === 'selected-content')) {
155
169
  return;
156
170
  }
157
171
 
158
172
  const radios = this.getAllRadios().filter(radio => !radio.disabled);
159
173
  const checkedRadio = radios.find(radio => radio.checked) ?? radios[0];
160
- const incr = event.key === ' ' ? 0 : ['ArrowUp', 'ArrowLeft'].includes(event.key) ? -1 : 1;
161
- let index = radios.indexOf(checkedRadio) + incr;
174
+ const isSelectKey = event.key === ' ' || event.key === 'Enter';
175
+ // Each radio is its own tab stop, so Space/Enter select the focused radio rather than the checked one.
176
+ const focusedRadio = (event.target as HTMLElement).closest<ZnRadio>('zn-radio');
177
+ const fromRadio = isSelectKey && focusedRadio && radios.includes(focusedRadio) ? focusedRadio : checkedRadio;
178
+ const incr = isSelectKey ? 0 : ['ArrowUp', 'ArrowLeft'].includes(event.key) ? -1 : 1;
179
+ let index = radios.indexOf(fromRadio) + incr;
162
180
 
163
181
  if (index < 0) {
164
182
  index = radios.length - 1;
@@ -172,10 +190,14 @@ export default class ZnRadioGroup extends ZincElement implements ZincFormControl
172
190
  radio.checked = false;
173
191
  });
174
192
 
193
+ const oldValue = this.value;
175
194
  this.value = radios[index].value;
176
195
  radios[index].checked = true;
177
- radios[index].shadowRoot!.querySelector('button')!.focus();
196
+ radios[index].focus();
178
197
 
198
+ if (this.value !== oldValue) {
199
+ this.emit('zn-change');
200
+ }
179
201
 
180
202
  event.preventDefault();
181
203
  }
@@ -204,12 +226,22 @@ export default class ZnRadioGroup extends ZincElement implements ZincFormControl
204
226
  if (this.square) {
205
227
  radio.square = true;
206
228
  }
229
+
230
+ if (this.centered) {
231
+ radio.centered = true;
232
+ }
207
233
  })
208
234
  );
235
+ this.syncScale();
236
+ }
209
237
 
210
- if (radios.length > 0 && !radios.some(radio => radio.checked)) {
211
- radios[0].setAttribute('tabindex', '0');
212
- }
238
+ private syncScale() {
239
+ const radios = this.getAllRadios();
240
+ const checkedIndex = radios.findIndex(radio => radio.value === this.value);
241
+ radios.forEach((radio, index) => {
242
+ radio.scale = this.scale;
243
+ radio.filled = this.scale && checkedIndex >= 0 && index <= checkedIndex;
244
+ });
213
245
  }
214
246
 
215
247
  private syncRadios() {
@@ -228,6 +260,7 @@ export default class ZnRadioGroup extends ZincElement implements ZincFormControl
228
260
  private updateCheckedRadio() {
229
261
  const radios = this.getAllRadios();
230
262
  radios.forEach(radio => (radio.checked = radio.value === this.value));
263
+ this.syncScale();
231
264
  this.formControlController.setValidity(this.validity?.valid);
232
265
  }
233
266
 
@@ -236,6 +269,11 @@ export default class ZnRadioGroup extends ZincElement implements ZincFormControl
236
269
  this.syncRadios();
237
270
  }
238
271
 
272
+ @watch('scale', {waitUntilFirstUpdate: true})
273
+ handleScaleChange() {
274
+ this.syncRadios();
275
+ }
276
+
239
277
  @watch('value')
240
278
  handleValueChange() {
241
279
  if (this.hasUpdated) {
@@ -54,6 +54,23 @@
54
54
  height: auto;
55
55
  }
56
56
 
57
+ /* A scale is one row of equal segments, however many radios it has. */
58
+ :host([scale]) .form-control-input {
59
+ display: grid;
60
+ grid-auto-flow: column;
61
+ grid-auto-columns: minmax(0, 1fr);
62
+ // Wider than the checked segment's ring and its offset, so the ring never touches a neighbour.
63
+ gap: var(--zn-spacing-x-small);
64
+ }
65
+
66
+ :host([scale]) .form-control--has-label .form-control-input {
67
+ margin-top: var(--zn-spacing-small);
68
+ }
69
+
70
+ :host([scale]) ::slotted(zn-radio) {
71
+ margin-top: 0;
72
+ }
73
+
57
74
  .form-control {
58
75
  position: relative;
59
76
  border: none;
@@ -8,9 +8,9 @@ describe('<zn-radio-group>', () => {
8
8
  expect(el).to.exist;
9
9
  });
10
10
 
11
- it('propagates contained and square to its radios', async () => {
11
+ it('propagates contained, square and centered to its radios', async () => {
12
12
  const el = await fixture<HTMLElement>(html`
13
- <zn-radio-group contained square>
13
+ <zn-radio-group contained square centered>
14
14
  <zn-radio value="one">One</zn-radio>
15
15
  <zn-radio value="two">Two</zn-radio>
16
16
  </zn-radio-group>
@@ -20,6 +20,67 @@ describe('<zn-radio-group>', () => {
20
20
  el.querySelectorAll('zn-radio').forEach(child => {
21
21
  expect(child).to.have.attribute('contained');
22
22
  expect(child).to.have.attribute('square');
23
+ expect(child).to.have.attribute('centered');
23
24
  });
24
25
  });
26
+
27
+ it('selects the next radio and emits zn-change on arrow key', async () => {
28
+ const el = await fixture<HTMLElement & { value: string }>(html`
29
+ <zn-radio-group value="one">
30
+ <zn-radio value="one">One</zn-radio>
31
+ <zn-radio value="two">Two</zn-radio>
32
+ </zn-radio-group>
33
+ `);
34
+ const radios = el.querySelectorAll<HTMLElement & { checked: boolean }>('zn-radio');
35
+ let changes = 0;
36
+ el.addEventListener('zn-change', () => changes++);
37
+
38
+ radios[0].dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true, composed: true}));
39
+
40
+ expect(el.value).to.equal('two');
41
+ expect(radios[1].checked).to.be.true;
42
+ expect(changes).to.equal(1);
43
+ });
44
+
45
+ it('selects the focused radio on Enter', async () => {
46
+ const el = await fixture<HTMLElement & { value: string }>(html`
47
+ <zn-radio-group value="one">
48
+ <zn-radio value="one">One</zn-radio>
49
+ <zn-radio value="two">Two</zn-radio>
50
+ <zn-radio value="three">Three</zn-radio>
51
+ </zn-radio-group>
52
+ `);
53
+ const radios = el.querySelectorAll<HTMLElement & { checked: boolean }>('zn-radio');
54
+ let changes = 0;
55
+ el.addEventListener('zn-change', () => changes++);
56
+
57
+ radios[2].dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, composed: true}));
58
+
59
+ expect(el.value).to.equal('three');
60
+ expect(radios[2].checked).to.be.true;
61
+ expect(changes).to.equal(1);
62
+ });
63
+
64
+ it('fills a scale up to the checked radio and leaves it unanswered until one is picked', async () => {
65
+ const el = await fixture<HTMLElement & { value: string }>(html`
66
+ <zn-radio-group scale>
67
+ <zn-radio value="1">1</zn-radio>
68
+ <zn-radio value="2">2</zn-radio>
69
+ <zn-radio value="3">3</zn-radio>
70
+ </zn-radio-group>
71
+ `);
72
+ await new Promise(resolve => requestAnimationFrame(resolve));
73
+ const radios = [...el.querySelectorAll<HTMLElement & { filled: boolean }>('zn-radio')];
74
+
75
+ expect(el.value).to.equal('');
76
+ radios.forEach(radio => {
77
+ expect(radio).to.have.attribute('scale');
78
+ expect(radio.filled).to.be.false;
79
+ });
80
+
81
+ el.value = '2';
82
+ await new Promise(resolve => requestAnimationFrame(resolve));
83
+
84
+ expect(radios.map(radio => radio.filled)).to.deep.equal([true, true, false]);
85
+ });
25
86
  });
@@ -154,6 +154,27 @@ export default class ZnRating extends ZincElement implements ZincFormControl {
154
154
  }
155
155
 
156
156
 
157
+ private _handleKeyDown(event: KeyboardEvent) {
158
+ if (this.disabled || this.readonly) {
159
+ return;
160
+ }
161
+
162
+ const step = {ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1}[event.key];
163
+ let value: number;
164
+ if (event.key === 'Home') {
165
+ value = 0;
166
+ } else if (event.key === 'End') {
167
+ value = this.max;
168
+ } else if (step !== undefined) {
169
+ value = Math.min(Math.max(this.value + step * this.precision, 0), this.max);
170
+ } else {
171
+ return;
172
+ }
173
+
174
+ event.preventDefault();
175
+ this.value = value;
176
+ }
177
+
157
178
  private _handleClick(event: MouseEvent) {
158
179
  if (this.readonly || this.disabled) {
159
180
  return;
@@ -231,6 +252,8 @@ export default class ZnRating extends ZincElement implements ZincFormControl {
231
252
  aria-valuenow="${this.value}"
232
253
  aria-valuemin="0"
233
254
  aria-valuemax="${this.max}"
255
+ tabindex="${this.disabled ? '-1' : '0'}"
256
+ @keydown="${this._handleKeyDown}"
234
257
  >
235
258
  <span
236
259
  class="rating__symbols"
@@ -23,7 +23,7 @@
23
23
  }
24
24
 
25
25
  &:focus-visible {
26
- outline: rgb(var(--zn-primary));
26
+ outline: var(--zn-focus-ring);
27
27
  outline-offset: 2px;
28
28
  }
29
29
 
@@ -4,6 +4,7 @@ import {continueList, indentList, isEmptyListItem} from './list-continuation';
4
4
  import {type CSSResultGroup, html, type PropertyValues, type TemplateResult, unsafeCSS} from 'lit';
5
5
  import {defaultValue} from "../../internal/default-value";
6
6
  import {FormControlController} from "../../internal/form";
7
+ import {ifDefined} from "lit/directives/if-defined.js";
7
8
  import {pastedRemarkd} from './paste';
8
9
  import {property, query, state} from 'lit/decorators.js';
9
10
  import {parse as remarkdParse} from "remarkd-js";
@@ -652,7 +653,11 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
652
653
  }
653
654
  }
654
655
 
655
- private handleRenderedClick(e: MouseEvent, index: number) {
656
+ private get editable() {
657
+ return !this.disabled && !this.readonly;
658
+ }
659
+
660
+ private handleRenderedClick(e: Event, index: number) {
656
661
  const checkbox = (e.target as HTMLElement).closest<HTMLInputElement>('input[type="checkbox"]');
657
662
  if (checkbox) {
658
663
  // Toggle the task in the source rather than opening the editor.
@@ -791,7 +796,9 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
791
796
  private handleImageControlsKeydown = (e: KeyboardEvent) => {
792
797
  if (e.key === 'Escape') {
793
798
  e.preventDefault();
799
+ const index = this.editingIndex;
794
800
  this.closeImageEdit();
801
+ if (index !== null) void this.focusRenderedBlock(index);
795
802
  } else if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') {
796
803
  e.preventDefault();
797
804
  this.saveImageEdit();
@@ -992,13 +999,40 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
992
999
  this.insertDraftBlock(next);
993
1000
  } else if (e.key === 'Escape' || (e.key === 'Enter' && (e.metaKey || e.ctrlKey))) {
994
1001
  e.preventDefault();
995
- input.blur();
1002
+ this.suppressBlurCommit = true;
1003
+ const index = this.editingIndex ?? 0;
1004
+ this.commitEdit();
1005
+ void this.focusRenderedBlock(index);
996
1006
  } else if (e.key === 'Backspace' && input.value === '') {
997
1007
  e.preventDefault();
998
- input.blur();
1008
+ this.suppressBlurCommit = true;
1009
+ const index = this.editingIndex ?? 0;
1010
+ this.commitEdit();
1011
+ void this.focusRenderedBlock(index - 1);
999
1012
  }
1000
1013
  };
1001
1014
 
1015
+ /** Focuses the rendered block at `index`, clamped to the blocks there are; the add row when there are none. */
1016
+ private async focusRenderedBlock(index: number) {
1017
+ await this.updateComplete;
1018
+ this.suppressBlurCommit = false;
1019
+ const blocks = this.shadowRoot?.querySelectorAll('.remarkd-editor__block') ?? [];
1020
+ const block = blocks[Math.max(0, Math.min(index, blocks.length - 1))];
1021
+ const target = block?.querySelector<HTMLElement>(':scope > .remarkd-editor__rendered')
1022
+ ?? this.shadowRoot?.querySelector<HTMLElement>('.remarkd-editor__add');
1023
+ target?.focus({preventScroll: true});
1024
+ this.reveal(target);
1025
+ }
1026
+
1027
+ private moveBlock(from: number, to: number) {
1028
+ if (!this.editable || to < 0 || to >= this.blocks.length) return;
1029
+ const blocks = [...this.blocks];
1030
+ const [moved] = blocks.splice(from, 1);
1031
+ blocks.splice(to, 0, moved);
1032
+ this.updateBlocks(blocks);
1033
+ void this.focusRenderedBlock(to);
1034
+ }
1035
+
1002
1036
  /**
1003
1037
  * The draft to commit when Enter should finish the block — pressed on a trailing blank line or
1004
1038
  * empty list item, outside any container — with that line dropped. Null otherwise.
@@ -1865,7 +1899,19 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1865
1899
  // its own inner body div carries the marker instead, see renderConditional.
1866
1900
  'remarkd-editor__rendered--parsed': !include && !conditional && !meta,
1867
1901
  })}
1868
- @click=${(e: MouseEvent) => this.handleRenderedClick(e, index)}>${
1902
+ tabindex=${ifDefined(this.editable ? '0' : undefined)}
1903
+ @click=${(e: MouseEvent) => this.handleRenderedClick(e, index)}
1904
+ @keydown=${(e: KeyboardEvent) => {
1905
+ // Links and task checkboxes inside the block keep their own keys
1906
+ if (e.target !== e.currentTarget) return;
1907
+ if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
1908
+ e.preventDefault();
1909
+ this.moveBlock(index, index + (e.key === 'ArrowUp' ? -1 : 1));
1910
+ } else if (e.key === 'Enter') {
1911
+ e.preventDefault();
1912
+ this.handleRenderedClick(e, index);
1913
+ }
1914
+ }}>${
1869
1915
  include ? this.renderIncludeChip(include)
1870
1916
  : conditional ? conditional
1871
1917
  : meta ? this.renderMetaChips(meta)
@@ -2012,7 +2058,16 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
2012
2058
  })}>
2013
2059
  ${this.rawMode ? this.renderRaw() : html`
2014
2060
  ${this.renderBody()}
2015
- <div class="remarkd-editor__add" @click=${() => this.insertDraftBlock(this.blocks.length)}>
2061
+ <div class="remarkd-editor__add"
2062
+ role=${ifDefined(this.editable ? 'button' : undefined)}
2063
+ tabindex=${ifDefined(this.editable ? '0' : undefined)}
2064
+ aria-label=${ifDefined(this.editable ? 'Add block' : undefined)}
2065
+ @click=${() => this.insertDraftBlock(this.blocks.length)}
2066
+ @keydown=${(e: KeyboardEvent) => {
2067
+ if (e.key !== 'Enter' && e.key !== ' ') return;
2068
+ e.preventDefault();
2069
+ this.insertDraftBlock(this.blocks.length);
2070
+ }}>
2016
2071
  ${this.blocks.length === 0 && this.editingIndex === null ? this.placeholder : ''}
2017
2072
  </div>`}
2018
2073
  </div>
@@ -187,6 +187,14 @@
187
187
  }
188
188
  }
189
189
 
190
+ .remarkd-editor__rendered,
191
+ .remarkd-editor__add {
192
+ &:focus-visible {
193
+ outline: var(--zn-focus-ring);
194
+ border-radius: var(--zn-border-radius);
195
+ }
196
+ }
197
+
190
198
  // The shell keeps the block's rendered chrome while its source is edited —
191
199
  // remarkd hint classes (hint-note, hint-tip, …) apply their own styling here.
192
200
  .remarkd-editor__edit-shell {
@@ -248,10 +256,10 @@
248
256
  }
249
257
 
250
258
  .remarkd-editor__edit-shell--code {
251
- background: var(--rmd-code-bg, #0f1626);
259
+ background: var(--rmd-code-bg, var(--zn-code-background, #0f1626));
252
260
 
253
261
  .remarkd-editor__input {
254
- color: var(--rmd-code-text, #f5f8ff);
262
+ color: var(--rmd-code-text, var(--zn-code-color, #f5f8ff));
255
263
  }
256
264
  }
257
265
 
@@ -299,7 +307,7 @@
299
307
  align-items: center;
300
308
  gap: 4px;
301
309
  margin-bottom: var(--zn-spacing-x-small);
302
- color: rgb(var(--zn-primary));
310
+ color: var(--zn-color-primary-fg, rgb(var(--zn-primary)));
303
311
  font-size: 0.75rem;
304
312
  text-transform: uppercase;
305
313
  letter-spacing: 0.04em;
@@ -328,7 +336,7 @@
328
336
  padding: 0 2px;
329
337
  border-radius: 3px;
330
338
  background: rgba(var(--zn-primary), 0.12);
331
- color: rgb(var(--zn-primary));
339
+ color: var(--zn-color-primary-fg, rgb(var(--zn-primary)));
332
340
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
333
341
  }
334
342
 
@@ -0,0 +1,12 @@
1
+ import ZnReportTable from './report-table.component';
2
+
3
+ export * from './report-table.component';
4
+ export default ZnReportTable;
5
+
6
+ ZnReportTable.define('zn-report-table');
7
+
8
+ declare global {
9
+ interface HTMLElementTagNameMap {
10
+ 'zn-report-table': ZnReportTable;
11
+ }
12
+ }
@@ -0,0 +1,86 @@
1
+ import {expect} from '@open-wc/testing';
2
+ import {formatValue, toneClass, unitLabel} from './report-format';
3
+ import type {ReportColumn} from './report-math';
4
+
5
+ const col = (c: Partial<ReportColumn>): ReportColumn => ({key: 'k', label: 'K', ...c});
6
+ const opts = {currency: 'USD', locale: 'en-US'};
7
+
8
+ describe('formatValue', () => {
9
+ it('formats currency with bracketed negatives', () => {
10
+ const c = col({format: 'currency'});
11
+ expect(formatValue(c, -1396.73, opts)).to.equal('($1,396.73)');
12
+ expect(formatValue(c, 3000.39, opts)).to.equal('$3,000.39');
13
+ expect(formatValue(c, -0.0001, opts)).to.equal('$0.00');
14
+ });
15
+
16
+ it('shows the narrow currency symbol in any locale', () => {
17
+ const c = col({format: 'currency'});
18
+ expect(formatValue(c, 3000.39, {currency: 'USD', locale: 'en-GB'})).to.equal('$3,000.39');
19
+ expect(formatValue(c, -1396.73, {currency: 'USD', locale: 'en-GB'})).to.equal('($1,396.73)');
20
+ });
21
+
22
+ it('formats financial in decimal style', () => {
23
+ expect(formatValue(col({format: 'financial'}), -1396.73, opts)).to.equal('(1,396.73)');
24
+ });
25
+
26
+ it('formats numbers with a leading minus', () => {
27
+ expect(formatValue(col({format: 'number'}), -32128, opts)).to.equal('-32,128');
28
+ expect(formatValue(col({format: 'number'}), -0.2, opts)).to.equal('0');
29
+ });
30
+
31
+ it('formats durations in seconds as hours, minutes and seconds', () => {
32
+ expect(formatValue(col({format: 'duration'}), 5400, opts)).to.equal('01:30:00');
33
+ expect(formatValue(col({format: 'duration'}), 59, opts)).to.equal('00:00:59');
34
+ expect(formatValue(col({format: 'duration'}), 453906, opts)).to.equal('126:05:06');
35
+ expect(formatValue(col({format: 'duration'}), -3600, opts)).to.equal('-01:00:00');
36
+ });
37
+
38
+ it('labels the unit of durations, currency and percent', () => {
39
+ expect(unitLabel(col({format: 'duration'}), 'USD')).to.equal('hh:mm:ss');
40
+ expect(unitLabel(col({format: 'currency'}), 'USD')).to.equal('USD');
41
+ expect(unitLabel(col({format: 'currency', currency: 'GBP'}), 'USD')).to.equal('GBP');
42
+ expect(unitLabel(col({format: 'percent'}), 'USD')).to.equal('%');
43
+ expect(unitLabel(col({format: 'number'}), 'USD')).to.equal('');
44
+ });
45
+
46
+ it('formats percent', () => {
47
+ expect(formatValue(col({format: 'percent', decimals: 2}), -0.0157, opts)).to.equal('-1.57%');
48
+ });
49
+
50
+ it('defaults to number format with 0 decimals', () => {
51
+ expect(formatValue(col({}), 1234.5, opts)).to.equal('1,235');
52
+ });
53
+
54
+ it('shows no sign on rounded zeros for financial and percent', () => {
55
+ expect(formatValue(col({format: 'financial'}), -0.0001, opts)).to.equal('0.00');
56
+ expect(formatValue(col({format: 'percent'}), -0.00001, opts)).to.equal('0.00%');
57
+ });
58
+
59
+ it('honours the column currency override', () => {
60
+ expect(formatValue(col({format: 'currency', currency: 'EUR'}), 5, opts)).to.contain('€');
61
+ });
62
+
63
+ it('handles null and text', () => {
64
+ expect(formatValue(col({format: 'number'}), null, opts)).to.equal('–');
65
+ expect(formatValue(col({format: 'text'}), 'abc', opts)).to.equal('abc');
66
+ });
67
+ });
68
+
69
+ describe('toneClass', () => {
70
+ it('colours breakeven columns', () => {
71
+ expect(toneClass(col({style: 'breakeven'}), 5)).to.equal('report__pos');
72
+ expect(toneClass(col({style: 'breakeven'}), -5)).to.equal('report__neg');
73
+ expect(toneClass(col({style: 'ibreakeven'}), 5)).to.equal('report__neg');
74
+ expect(toneClass(col({style: 'none'}), -5)).to.equal('');
75
+ });
76
+
77
+ it('scales percent values to display units', () => {
78
+ const c = col({style: 'breakeven', format: 'percent'});
79
+ expect(toneClass(c, 0.004)).to.equal('report__pos');
80
+ expect(toneClass(c, -0.00004)).to.equal('');
81
+ });
82
+
83
+ it('ignores values that round to the threshold', () => {
84
+ expect(toneClass(col({style: 'breakeven', format: 'currency'}), -0.0001)).to.equal('');
85
+ });
86
+ });
@@ -0,0 +1,72 @@
1
+ import type {ReportColumn} from './report-math';
2
+
3
+ export function displayDecimals(column: ReportColumn): number {
4
+ return column.decimals ?? ((column.format ?? 'number') === 'number' ? 0 : 2);
5
+ }
6
+
7
+ function round(value: number, decimals: number): number {
8
+ const rounded = Number(value.toFixed(decimals));
9
+ return rounded === 0 ? 0 : rounded;
10
+ }
11
+
12
+ export function formatValue(column: ReportColumn, value: number | string | null, opts: {currency: string; locale?: string}): string {
13
+ if (value === null || value === undefined) {
14
+ return '–';
15
+ }
16
+ const format = column.format ?? 'number';
17
+ if (format === 'text' || typeof value === 'string') {
18
+ return String(value);
19
+ }
20
+
21
+ if (format === 'duration') {
22
+ const seconds = Math.round(Math.abs(value));
23
+ const pad = (part: number) => String(part).padStart(2, '0');
24
+ const text = `${pad(Math.floor(seconds / 3600))}:${pad(Math.floor(seconds / 60) % 60)}:${pad(seconds % 60)}`;
25
+ return value < 0 && seconds > 0 ? `-${text}` : text;
26
+ }
27
+
28
+ const decimals = displayDecimals(column);
29
+ const scaled = format === 'percent' ? value * 100 : value;
30
+ const rounded = round(scaled, decimals);
31
+ const digits = {minimumFractionDigits: decimals, maximumFractionDigits: decimals};
32
+ const abs = Math.abs(rounded);
33
+
34
+ if (format === 'currency' || format === 'financial') {
35
+ const style = format === 'currency'
36
+ ? {style: 'currency' as const, currency: column.currency ?? opts.currency, currencyDisplay: 'narrowSymbol' as const}
37
+ : {style: 'decimal' as const};
38
+ const text = new Intl.NumberFormat(opts.locale, {...style, ...digits}).format(abs);
39
+ return rounded < 0 ? `(${text})` : text;
40
+ }
41
+
42
+ const text = new Intl.NumberFormat(opts.locale, {style: 'decimal', ...digits}).format(abs);
43
+ const signed = rounded < 0 ? `-${text}` : text;
44
+ return format === 'percent' ? `${signed}%` : signed;
45
+ }
46
+
47
+ export function unitLabel(column: ReportColumn, currency: string): string {
48
+ switch (column.format) {
49
+ case 'duration':
50
+ return 'hh:mm:ss';
51
+ case 'currency':
52
+ return column.currency ?? currency;
53
+ case 'percent':
54
+ return '%';
55
+ default:
56
+ return '';
57
+ }
58
+ }
59
+
60
+ export function toneClass(column: ReportColumn, value: number | null): 'report__pos' | 'report__neg' | '' {
61
+ if ((column.style !== 'breakeven' && column.style !== 'ibreakeven') || value === null || value === undefined) {
62
+ return '';
63
+ }
64
+ const decimals = displayDecimals(column);
65
+ const scale = column.format === 'percent' ? 100 : 1;
66
+ const diff = round(value * scale, decimals) - round((column.threshold ?? 0) * scale, decimals);
67
+ if (round(diff, decimals) === 0) {
68
+ return '';
69
+ }
70
+ const positive = diff > 0;
71
+ return positive === (column.style === 'breakeven') ? 'report__pos' : 'report__neg';
72
+ }
@@ -0,0 +1,74 @@
1
+ import {aggregate, columnValue, compileFormula, computeTotals} from './report-math';
2
+ import {expect} from '@open-wc/testing';
3
+ import type { ReportColumn, ReportRow, ReportValues} from './report-math';
4
+
5
+ describe('report-math', () => {
6
+ it('evaluates arithmetic with precedence, parentheses, unary minus and a leading =', () => {
7
+ expect(compileFormula('1 + 2 * 3')({})).to.equal(7);
8
+ expect(compileFormula('(1 + 2) * 3')({})).to.equal(9);
9
+ expect(compileFormula('-{a} + 4')({a: 1})).to.equal(3);
10
+ expect(compileFormula('=10 / 4')({})).to.equal(2.5);
11
+ });
12
+
13
+ it('resolves placeholders to 0 when missing, zero-divided or non-numeric, and parses numeric strings', () => {
14
+ expect(compileFormula('{a} / {b}')({a: 5, b: 0})).to.equal(0);
15
+ expect(compileFormula('{x.y.z}')({})).to.equal(0);
16
+ expect(compileFormula('{a}')({a: '12.5'})).to.equal(12.5);
17
+ });
18
+
19
+ it('evaluates min and max with any number of arguments', () => {
20
+ expect(compileFormula('max(0, {a} - {b})')({a: 1, b: 5})).to.equal(0);
21
+ expect(compileFormula('min(1, {a} / {b})')({a: 9, b: 3})).to.equal(1);
22
+ expect(compileFormula('MAX(1, 2, 3)')({})).to.equal(3);
23
+ });
24
+
25
+ it('throws SyntaxError on bad syntax', () => {
26
+ expect(() => compileFormula('1 +')).to.throw(SyntaxError);
27
+ expect(() => compileFormula('{a')).to.throw(SyntaxError);
28
+ expect(() => compileFormula('sum(1, 2)')).to.throw(SyntaxError);
29
+ expect(() => compileFormula('max 1')).to.throw(SyntaxError);
30
+ });
31
+
32
+ it('shows 0 for an invalid formula and warns once', () => {
33
+ const original = console.warn;
34
+ const warnings: unknown[] = [];
35
+ console.warn = (...args: unknown[]) => warnings.push(args);
36
+ try {
37
+ const column: ReportColumn = {key: 'x', label: 'X', formula: '{a} * (2'};
38
+ expect(columnValue(column, {a: 3})).to.equal(0);
39
+ expect(columnValue(column, {a: 4})).to.equal(0);
40
+ expect(warnings).to.have.length(1);
41
+ expect(() => compileFormula('{a} * (2')).to.throw(SyntaxError);
42
+ } finally {
43
+ console.warn = original;
44
+ }
45
+ });
46
+
47
+ it('aggregates numeric leaves by path and skips non-numeric strings', () => {
48
+ expect(aggregate([
49
+ {d1: {i: {unique: 2}}, label: 'x'},
50
+ {d1: {i: {unique: 3}}, d2: {total: {revenue: '1.5'}}},
51
+ ])).to.deep.equal({d1: {i: {unique: 5}}, d2: {total: {revenue: 1.5}}});
52
+ });
53
+
54
+ it('ignores prototype-polluting keys when aggregating', () => {
55
+ aggregate([JSON.parse('{"__proto__": {"polluted": 1}}') as ReportValues]);
56
+ expect(({} as Record<string, unknown>).polluted).to.be.undefined;
57
+ });
58
+
59
+ it('recalculates ratio totals from summed inputs', () => {
60
+ const columns: ReportColumn[] = [{key: 'r', label: 'R', format: 'percent', formula: '{a} / {b}'}];
61
+ const rows: ReportRow[] = [{a: 1, b: 10}, {a: 9, b: 10}, {a: 0, b: 80}].map((values, i) => ({id: `${i}`, label: `${i}`, values}));
62
+ expect(computeTotals(columns, rows).r).to.equal(0.1);
63
+ });
64
+
65
+ it('returns null totals for non-summable and text columns', () => {
66
+ const columns: ReportColumn[] = [
67
+ {key: 'a', label: 'A', summable: false},
68
+ {key: 'b', label: 'B', format: 'text'},
69
+ ];
70
+ const totals = computeTotals(columns, [{id: '1', label: '1', values: {a: 1, b: 2}}]);
71
+ expect(totals.a).to.be.null;
72
+ expect(totals.b).to.be.null;
73
+ });
74
+ });