@automattic/charts 0.56.7 → 0.58.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 (223) hide show
  1. package/AGENTS.md +28 -98
  2. package/CHANGELOG.md +30 -0
  3. package/dist/charts/bar-chart/index.cjs +7 -6
  4. package/dist/charts/bar-chart/index.cjs.map +1 -1
  5. package/dist/charts/bar-chart/index.css +12 -24
  6. package/dist/charts/bar-chart/index.css.map +1 -1
  7. package/dist/charts/bar-chart/index.d.cts +3 -4
  8. package/dist/charts/bar-chart/index.d.ts +3 -4
  9. package/dist/charts/bar-chart/index.js +6 -5
  10. package/dist/charts/bar-list-chart/index.cjs +8 -7
  11. package/dist/charts/bar-list-chart/index.cjs.map +1 -1
  12. package/dist/charts/bar-list-chart/index.css +12 -24
  13. package/dist/charts/bar-list-chart/index.css.map +1 -1
  14. package/dist/charts/bar-list-chart/index.d.cts +3 -3
  15. package/dist/charts/bar-list-chart/index.d.ts +3 -3
  16. package/dist/charts/bar-list-chart/index.js +7 -6
  17. package/dist/charts/conversion-funnel-chart/index.cjs +5 -6
  18. package/dist/charts/conversion-funnel-chart/index.cjs.map +1 -1
  19. package/dist/charts/conversion-funnel-chart/index.css +0 -94
  20. package/dist/charts/conversion-funnel-chart/index.css.map +1 -1
  21. package/dist/charts/conversion-funnel-chart/index.d.cts +1 -1
  22. package/dist/charts/conversion-funnel-chart/index.d.ts +1 -1
  23. package/dist/charts/conversion-funnel-chart/index.js +4 -5
  24. package/dist/charts/geo-chart/index.cjs +4 -4
  25. package/dist/charts/geo-chart/index.css +0 -94
  26. package/dist/charts/geo-chart/index.css.map +1 -1
  27. package/dist/charts/geo-chart/index.d.cts +1 -1
  28. package/dist/charts/geo-chart/index.d.ts +1 -1
  29. package/dist/charts/geo-chart/index.js +3 -3
  30. package/dist/charts/leaderboard-chart/index.cjs +7 -6
  31. package/dist/charts/leaderboard-chart/index.cjs.map +1 -1
  32. package/dist/charts/leaderboard-chart/index.css +20 -33
  33. package/dist/charts/leaderboard-chart/index.css.map +1 -1
  34. package/dist/charts/leaderboard-chart/index.d.cts +3 -3
  35. package/dist/charts/leaderboard-chart/index.d.ts +3 -3
  36. package/dist/charts/leaderboard-chart/index.js +6 -5
  37. package/dist/charts/line-chart/index.cjs +7 -6
  38. package/dist/charts/line-chart/index.cjs.map +1 -1
  39. package/dist/charts/line-chart/index.css +12 -24
  40. package/dist/charts/line-chart/index.css.map +1 -1
  41. package/dist/charts/line-chart/index.d.cts +3 -4
  42. package/dist/charts/line-chart/index.d.ts +3 -4
  43. package/dist/charts/line-chart/index.js +6 -5
  44. package/dist/charts/pie-chart/index.cjs +7 -7
  45. package/dist/charts/pie-chart/index.css +12 -24
  46. package/dist/charts/pie-chart/index.css.map +1 -1
  47. package/dist/charts/pie-chart/index.d.cts +7 -13
  48. package/dist/charts/pie-chart/index.d.ts +7 -13
  49. package/dist/charts/pie-chart/index.js +6 -6
  50. package/dist/charts/pie-semi-circle-chart/index.cjs +7 -7
  51. package/dist/charts/pie-semi-circle-chart/index.css +12 -24
  52. package/dist/charts/pie-semi-circle-chart/index.css.map +1 -1
  53. package/dist/charts/pie-semi-circle-chart/index.d.cts +7 -13
  54. package/dist/charts/pie-semi-circle-chart/index.d.ts +7 -13
  55. package/dist/charts/pie-semi-circle-chart/index.js +6 -6
  56. package/dist/charts/sparkline/index.cjs +8 -7
  57. package/dist/charts/sparkline/index.cjs.map +1 -1
  58. package/dist/charts/sparkline/index.css +12 -24
  59. package/dist/charts/sparkline/index.css.map +1 -1
  60. package/dist/charts/sparkline/index.js +7 -6
  61. package/dist/{chunk-RFSHE3HL.js → chunk-2I67QUIV.js} +84 -431
  62. package/dist/chunk-2I67QUIV.js.map +1 -0
  63. package/dist/{chunk-OMS5QIJN.js → chunk-2ICEEQOC.js} +31 -25
  64. package/dist/chunk-2ICEEQOC.js.map +1 -0
  65. package/dist/{chunk-GWBS65VC.js → chunk-4B7BL2DD.js} +3 -3
  66. package/dist/{chunk-7FDQGBY7.js → chunk-4OXMTKAL.js} +24 -24
  67. package/dist/chunk-4OXMTKAL.js.map +1 -0
  68. package/dist/{chunk-SSFFCBCF.js → chunk-B6NLZFRW.js} +32 -26
  69. package/dist/chunk-B6NLZFRW.js.map +1 -0
  70. package/dist/{chunk-3EXJP67N.cjs → chunk-BBAUQOW6.cjs} +9 -9
  71. package/dist/{chunk-3EXJP67N.cjs.map → chunk-BBAUQOW6.cjs.map} +1 -1
  72. package/dist/{chunk-NQJE2CC7.cjs → chunk-CMMHCTBX.cjs} +45 -45
  73. package/dist/chunk-CMMHCTBX.cjs.map +1 -0
  74. package/dist/{chunk-O2JIANHK.cjs → chunk-CPPXJATQ.cjs} +51 -45
  75. package/dist/chunk-CPPXJATQ.cjs.map +1 -0
  76. package/dist/{chunk-MDRCAGKZ.js → chunk-DKU775VC.js} +3 -3
  77. package/dist/{chunk-BXFD7JIG.cjs → chunk-GRA7Y2ZG.cjs} +46 -46
  78. package/dist/chunk-GRA7Y2ZG.cjs.map +1 -0
  79. package/dist/{chunk-TE63Y5PX.js → chunk-JJIMABHT.js} +10 -3
  80. package/dist/chunk-JJIMABHT.js.map +1 -0
  81. package/dist/{chunk-KHQPN77E.js → chunk-KJHWXOCZ.js} +4 -4
  82. package/dist/{chunk-6CCZL2JJ.js → chunk-KRWGSOJ2.js} +30 -2
  83. package/dist/chunk-KRWGSOJ2.js.map +1 -0
  84. package/dist/{chunk-VPAEBI2F.js → chunk-LTFH7SEG.js} +24 -24
  85. package/dist/chunk-LTFH7SEG.js.map +1 -0
  86. package/dist/{chunk-E62LCBGD.js → chunk-MUNOKLLE.js} +3 -3
  87. package/dist/{chunk-ZVGEDXDP.cjs → chunk-MXGLYWVP.cjs} +10 -3
  88. package/dist/chunk-MXGLYWVP.cjs.map +1 -0
  89. package/dist/{chunk-55ZCOYDF.cjs → chunk-OYC34VTO.cjs} +252 -827
  90. package/dist/chunk-OYC34VTO.cjs.map +1 -0
  91. package/dist/{chunk-CAFJRZPZ.cjs → chunk-PQL5I3F6.cjs} +17 -17
  92. package/dist/{chunk-CAFJRZPZ.cjs.map → chunk-PQL5I3F6.cjs.map} +1 -1
  93. package/dist/{chunk-UFRBUT2D.cjs → chunk-REZTQ4PH.cjs} +87 -24
  94. package/dist/chunk-REZTQ4PH.cjs.map +1 -0
  95. package/dist/{chunk-RCY6XLGU.cjs → chunk-TZRUHQOH.cjs} +36 -8
  96. package/dist/chunk-TZRUHQOH.cjs.map +1 -0
  97. package/dist/{chunk-XD2HV7M5.js → chunk-UTYVIOWZ.js} +226 -801
  98. package/dist/chunk-UTYVIOWZ.js.map +1 -0
  99. package/dist/{chunk-YAXY5L7I.cjs → chunk-W2LDIX26.cjs} +5 -5
  100. package/dist/{chunk-YAXY5L7I.cjs.map → chunk-W2LDIX26.cjs.map} +1 -1
  101. package/dist/{chunk-K6TGILHX.cjs → chunk-WSG64BVN.cjs} +6 -6
  102. package/dist/{chunk-K6TGILHX.cjs.map → chunk-WSG64BVN.cjs.map} +1 -1
  103. package/dist/chunk-WTQYGUNF.js +400 -0
  104. package/dist/chunk-WTQYGUNF.js.map +1 -0
  105. package/dist/{chunk-YDVHT7GS.cjs → chunk-WYK7EL5R.cjs} +100 -447
  106. package/dist/chunk-WYK7EL5R.cjs.map +1 -0
  107. package/dist/{chunk-X7JL2NYJ.cjs → chunk-XC4KHJYX.cjs} +51 -45
  108. package/dist/chunk-XC4KHJYX.cjs.map +1 -0
  109. package/dist/chunk-XVBH5XHE.cjs +400 -0
  110. package/dist/chunk-XVBH5XHE.cjs.map +1 -0
  111. package/dist/{chunk-IS5YYLTV.js → chunk-YAFQVVDI.js} +85 -22
  112. package/dist/chunk-YAFQVVDI.js.map +1 -0
  113. package/dist/components/legend/index.cjs +4 -3
  114. package/dist/components/legend/index.cjs.map +1 -1
  115. package/dist/components/legend/index.css +12 -24
  116. package/dist/components/legend/index.css.map +1 -1
  117. package/dist/components/legend/index.d.cts +4 -4
  118. package/dist/components/legend/index.d.ts +4 -4
  119. package/dist/components/legend/index.js +3 -2
  120. package/dist/components/tooltip/index.d.cts +1 -1
  121. package/dist/components/tooltip/index.d.ts +1 -1
  122. package/dist/hooks/index.cjs +3 -5
  123. package/dist/hooks/index.cjs.map +1 -1
  124. package/dist/hooks/index.css +0 -94
  125. package/dist/hooks/index.css.map +1 -1
  126. package/dist/hooks/index.d.cts +9 -13
  127. package/dist/hooks/index.d.ts +9 -13
  128. package/dist/hooks/index.js +2 -4
  129. package/dist/index.cjs +18 -17
  130. package/dist/index.cjs.map +1 -1
  131. package/dist/index.css +20 -33
  132. package/dist/index.css.map +1 -1
  133. package/dist/index.d.cts +6 -6
  134. package/dist/index.d.ts +6 -6
  135. package/dist/index.js +17 -16
  136. package/dist/{leaderboard-chart-COtgamhe.d.cts → leaderboard-chart-BSbg0ufV.d.cts} +3 -11
  137. package/dist/{leaderboard-chart-BSgEw_Um.d.ts → leaderboard-chart-odEYxxEC.d.ts} +3 -11
  138. package/dist/{legend-C9ahiwOt.d.cts → legend-DFkosEvC.d.cts} +1 -1
  139. package/dist/{legend-jjMmhSg3.d.ts → legend-DLswHhOk.d.ts} +1 -1
  140. package/dist/providers/index.cjs +3 -3
  141. package/dist/providers/index.css +0 -94
  142. package/dist/providers/index.css.map +1 -1
  143. package/dist/providers/index.d.cts +3 -3
  144. package/dist/providers/index.d.ts +3 -3
  145. package/dist/providers/index.js +2 -2
  146. package/dist/{themes-CVR5rmIs.d.cts → themes-D0qc5JaW.d.cts} +2 -2
  147. package/dist/{themes-DQzmaSze.d.ts → themes-itO4Ht5g.d.ts} +2 -2
  148. package/dist/{types-BBwg4Evw.d.cts → types-B5f6XQ7Q.d.cts} +1 -1
  149. package/dist/{types-DQNnq5Fr.d.ts → types-BsHooDbM.d.ts} +1 -1
  150. package/dist/{types-C05PdDJa.d.cts → types-BuSrRM4p.d.ts} +15 -23
  151. package/dist/{types-CzdN7rUe.d.cts → types-ChOUI9-N.d.cts} +90 -46
  152. package/dist/{types-CzdN7rUe.d.ts → types-ChOUI9-N.d.ts} +90 -46
  153. package/dist/{types-C05PdDJa.d.ts → types-Dfw9VOKI.d.cts} +15 -23
  154. package/dist/utils/index.cjs +2 -2
  155. package/dist/utils/index.d.cts +1 -1
  156. package/dist/utils/index.d.ts +1 -1
  157. package/dist/utils/index.js +1 -1
  158. package/package.json +10 -8
  159. package/src/charts/bar-chart/bar-chart.tsx +19 -19
  160. package/src/charts/bar-chart/test/bar-chart.test.tsx +78 -31
  161. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +2 -2
  162. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +0 -2
  163. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +9 -10
  164. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +124 -102
  165. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +61 -33
  166. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -5
  167. package/src/charts/leaderboard-chart/types.ts +2 -15
  168. package/src/charts/line-chart/line-chart.tsx +18 -17
  169. package/src/charts/line-chart/test/line-chart.test.tsx +49 -27
  170. package/src/charts/line-chart/types.ts +0 -1
  171. package/src/charts/pie-chart/pie-chart.tsx +23 -23
  172. package/src/charts/pie-chart/test/composition-api.test.tsx +41 -0
  173. package/src/charts/pie-chart/test/pie-chart.test.tsx +9 -9
  174. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +21 -24
  175. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +33 -5
  176. package/src/charts/private/chart-composition/index.ts +2 -0
  177. package/src/charts/private/chart-composition/render-legend-slot.ts +22 -0
  178. package/src/charts/private/chart-composition/test/render-legend-slot.test.tsx +60 -0
  179. package/src/charts/private/chart-composition/test/use-chart-children.test.tsx +91 -0
  180. package/src/charts/private/chart-composition/use-chart-children.ts +34 -2
  181. package/src/components/legend/private/base-legend.module.scss +19 -37
  182. package/src/components/legend/private/base-legend.tsx +32 -24
  183. package/src/components/legend/test/legend.test.tsx +148 -52
  184. package/src/components/legend/types.ts +23 -24
  185. package/src/hooks/index.ts +0 -1
  186. package/src/hooks/test/use-zero-value-display.test.tsx +206 -0
  187. package/src/hooks/use-zero-value-display.ts +52 -23
  188. package/src/providers/chart-context/test/chart-context.test.tsx +12 -6
  189. package/src/providers/chart-context/themes.ts +6 -4
  190. package/src/types.ts +93 -44
  191. package/src/utils/date-parsing.ts +10 -1
  192. package/src/utils/get-styles.ts +1 -1
  193. package/src/utils/test/date-parsing.test.ts +12 -0
  194. package/src/utils/test/get-styles.test.ts +12 -10
  195. package/src/utils/test/resolve-css-var.test.ts +2 -2
  196. package/tsup.config.ts +1 -1
  197. package/dist/chunk-55ZCOYDF.cjs.map +0 -1
  198. package/dist/chunk-6CCZL2JJ.js.map +0 -1
  199. package/dist/chunk-7FDQGBY7.js.map +0 -1
  200. package/dist/chunk-BXFD7JIG.cjs.map +0 -1
  201. package/dist/chunk-IS5YYLTV.js.map +0 -1
  202. package/dist/chunk-KNIMXN6Z.js +0 -51
  203. package/dist/chunk-KNIMXN6Z.js.map +0 -1
  204. package/dist/chunk-NQJE2CC7.cjs.map +0 -1
  205. package/dist/chunk-O2JIANHK.cjs.map +0 -1
  206. package/dist/chunk-OMS5QIJN.js.map +0 -1
  207. package/dist/chunk-RCY6XLGU.cjs.map +0 -1
  208. package/dist/chunk-RFSHE3HL.js.map +0 -1
  209. package/dist/chunk-SSFFCBCF.js.map +0 -1
  210. package/dist/chunk-SUDERBUA.cjs +0 -51
  211. package/dist/chunk-SUDERBUA.cjs.map +0 -1
  212. package/dist/chunk-TE63Y5PX.js.map +0 -1
  213. package/dist/chunk-UFRBUT2D.cjs.map +0 -1
  214. package/dist/chunk-VPAEBI2F.js.map +0 -1
  215. package/dist/chunk-X7JL2NYJ.cjs.map +0 -1
  216. package/dist/chunk-XD2HV7M5.js.map +0 -1
  217. package/dist/chunk-YDVHT7GS.cjs.map +0 -1
  218. package/dist/chunk-ZVGEDXDP.cjs.map +0 -1
  219. package/src/hooks/use-has-legend-child.ts +0 -22
  220. /package/dist/{chunk-GWBS65VC.js.map → chunk-4B7BL2DD.js.map} +0 -0
  221. /package/dist/{chunk-MDRCAGKZ.js.map → chunk-DKU775VC.js.map} +0 -0
  222. /package/dist/{chunk-KHQPN77E.js.map → chunk-KJHWXOCZ.js.map} +0 -0
  223. /package/dist/{chunk-E62LCBGD.js.map → chunk-MUNOKLLE.js.map} +0 -0
@@ -1,34 +1,33 @@
1
1
  import { LegendOrdinal } from '@visx/legend';
2
+ import type {
3
+ LegendItemStyles,
4
+ LegendLabelStyles,
5
+ LegendPosition,
6
+ LegendShapeStyles,
7
+ } from '../../types';
2
8
  import type { GlyphProps, LineStyles } from '@visx/xychart';
3
9
  import type { ComponentProps, CSSProperties, ReactNode } from 'react';
4
10
 
5
- // See https://airbnb.io/visx/docs/legend#Ordinal for more details.
6
- type LegendOrdinalProps = Omit< ComponentProps< typeof LegendOrdinal >, 'scale' | 'direction' >;
11
+ type VisxLegendProps = Pick<
12
+ ComponentProps< typeof LegendOrdinal >,
13
+ 'className' | 'shape' | 'fill' | 'size' | 'labelFormat' | 'labelTransform'
14
+ >;
7
15
 
8
- export type BaseLegendProps = Omit< LegendOrdinalProps, 'shapeStyle' > & {
16
+ export type BaseLegendProps = VisxLegendProps & {
9
17
  items: BaseLegendItem[];
10
18
  orientation?: 'horizontal' | 'vertical';
11
- /**
12
- * TODO: Add 'left' | 'right' positioning support in future implementation
13
- */
14
- position?: 'top' | 'bottom';
19
+ position?: LegendPosition;
15
20
  alignment?: 'start' | 'center' | 'end';
16
- /**
17
- * Maximum width for legend items. When set, text overflow behavior is controlled by textOverflow prop.
18
- * Should be a CSS value string (e.g. '200px', '50%', '10rem')
19
- */
20
- maxWidth?: string;
21
- /**
22
- * Controls how text behaves when it exceeds maxWidth.
23
- * - 'ellipsis': Truncate with ellipsis (ideal for widgets/small devices)
24
- * - 'wrap': Wrap text to multiple lines (default, ideal for larger displays)
25
- */
26
- textOverflow?: 'ellipsis' | 'wrap';
27
- /**
28
- * Additional CSS class name for legend items.
29
- * This allows consumers to customize individual legend item styling.
30
- */
31
- legendItemClassName?: string;
21
+ /** Additional CSS class name for legend items. */
22
+ itemClassName?: string;
23
+ /** CSS styles for each legend item (margin, flexDirection). */
24
+ itemStyles?: LegendItemStyles;
25
+ /** Additional CSS class name for legend labels. */
26
+ labelClassName?: string;
27
+ /** CSS styles for legend labels (justifyContent, flex, margin). */
28
+ labelStyles?: LegendLabelStyles;
29
+ /** Styles for legend shapes (width, height, margin). */
30
+ shapeStyles?: LegendShapeStyles;
32
31
  /**
33
32
  * Function for rendering a custom legend layout.
34
33
  */
@@ -51,7 +50,7 @@ export type LegendProps = Omit< BaseLegendProps, 'items' > & {
51
50
 
52
51
  export type BaseLegendItem = {
53
52
  label: string;
54
- value: number | string;
53
+ value?: number | string;
55
54
  color: string;
56
55
  glyphSize?: number;
57
56
  renderGlyph?: < Datum extends object >( props: GlyphProps< Datum > ) => ReactNode;
@@ -4,7 +4,6 @@ export { useXYChartTheme } from './use-xychart-theme';
4
4
  export { useChartDataTransform } from './use-chart-data-transform';
5
5
  export { useChartMargin } from './use-chart-margin';
6
6
  export { useElementSize } from './use-element-size';
7
- export { useHasLegendChild } from './use-has-legend-child';
8
7
  export { useTextTruncation } from './use-text-truncation';
9
8
  export { useZeroValueDisplay } from './use-zero-value-display';
10
9
  export { useInteractiveLegendData } from './use-interactive-legend-data';
@@ -0,0 +1,206 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { useZeroValueDisplay } from '../use-zero-value-display';
3
+ import type { SeriesData } from '../../types';
4
+
5
+ describe( 'useZeroValueDisplay', () => {
6
+ const mockData: SeriesData[] = [
7
+ {
8
+ label: 'Series 1',
9
+ data: [
10
+ { label: 'A', value: 0 },
11
+ { label: 'B', value: 100 },
12
+ { label: 'C', value: 200 },
13
+ ],
14
+ },
15
+ ];
16
+
17
+ test( 'returns original data when disabled', () => {
18
+ const { result } = renderHook( () =>
19
+ useZeroValueDisplay( mockData, { enabled: false, valueAxisLength: 100 } )
20
+ );
21
+ expect( result.current ).toBe( mockData );
22
+ } );
23
+
24
+ test( 'returns original data when valueAxisLength is not provided', () => {
25
+ const { result } = renderHook( () => useZeroValueDisplay( mockData, { enabled: true } ) );
26
+ expect( result.current ).toBe( mockData );
27
+ } );
28
+
29
+ test( 'adds visualValue for zero values', () => {
30
+ const { result } = renderHook( () =>
31
+ useZeroValueDisplay( mockData, { enabled: true, valueAxisLength: 100 } )
32
+ );
33
+
34
+ const enhancedData = result.current;
35
+ expect( enhancedData[ 0 ].data[ 0 ] ).toHaveProperty( 'visualValue' );
36
+ expect(
37
+ ( enhancedData[ 0 ].data[ 0 ] as { visualValue?: number } ).visualValue
38
+ ).toBeGreaterThan( 0 );
39
+ } );
40
+
41
+ test( 'adds visualValue for near-zero values that would render below minimum', () => {
42
+ const data: SeriesData[] = [
43
+ {
44
+ label: 'Series 1',
45
+ data: [
46
+ { label: 'A', value: 1 }, // Would render as 1px (below 3px minimum)
47
+ { label: 'B', value: 100 },
48
+ ],
49
+ },
50
+ ];
51
+
52
+ // With axis=100 and max=100, 3px threshold = 3
53
+ // Value of 1 < 3, so it gets boosted to 3
54
+ const { result } = renderHook( () =>
55
+ useZeroValueDisplay( data, { enabled: true, valueAxisLength: 100 } )
56
+ );
57
+
58
+ const enhancedData = result.current;
59
+ expect( enhancedData[ 0 ].data[ 0 ] ).toHaveProperty( 'visualValue' );
60
+ expect( ( enhancedData[ 0 ].data[ 0 ] as { visualValue?: number } ).visualValue ).toBe( 3 );
61
+ } );
62
+
63
+ test( 'does not add visualValue for values above minimum threshold', () => {
64
+ const data: SeriesData[] = [
65
+ {
66
+ label: 'Series 1',
67
+ data: [
68
+ { label: 'A', value: 10 }, // Would render as 10px (above 3px minimum)
69
+ { label: 'B', value: 100 },
70
+ ],
71
+ },
72
+ ];
73
+
74
+ const { result } = renderHook( () =>
75
+ useZeroValueDisplay( data, { enabled: true, valueAxisLength: 100 } )
76
+ );
77
+
78
+ const enhancedData = result.current;
79
+ expect( enhancedData[ 0 ].data[ 0 ] ).not.toHaveProperty( 'visualValue' );
80
+ expect( enhancedData[ 0 ].data[ 1 ] ).not.toHaveProperty( 'visualValue' );
81
+ } );
82
+
83
+ test( 'zero values get 2px equivalent (1px less than near-zero)', () => {
84
+ // mockData has values [0, 100, 200], max = 200
85
+ // zeroVisualValue = (2 / 100) * 200 = 4
86
+ const { result } = renderHook( () =>
87
+ useZeroValueDisplay( mockData, { enabled: true, valueAxisLength: 100 } )
88
+ );
89
+
90
+ const visualValue = ( result.current[ 0 ].data[ 0 ] as { visualValue?: number } ).visualValue;
91
+ expect( visualValue ).toBe( 4 ); // 2px equivalent
92
+ } );
93
+
94
+ test( 'near-zero values get 3px equivalent', () => {
95
+ const data: SeriesData[] = [
96
+ {
97
+ label: 'Series 1',
98
+ data: [
99
+ { label: 'A', value: 1 }, // Would render as 1px
100
+ { label: 'B', value: 100 },
101
+ ],
102
+ },
103
+ ];
104
+
105
+ // minNonZeroValue = (3 / 100) * 100 = 3
106
+ const { result } = renderHook( () =>
107
+ useZeroValueDisplay( data, { enabled: true, valueAxisLength: 100 } )
108
+ );
109
+
110
+ const visualValue = ( result.current[ 0 ].data[ 0 ] as { visualValue?: number } ).visualValue;
111
+ expect( visualValue ).toBe( 3 ); // 3px equivalent
112
+ } );
113
+
114
+ test( 'zeros and near-zeros have 1px visual difference', () => {
115
+ const data: SeriesData[] = [
116
+ {
117
+ label: 'Series 1',
118
+ data: [
119
+ { label: 'Zero', value: 0 },
120
+ { label: 'NearZero', value: 1 },
121
+ { label: 'Max', value: 100 },
122
+ ],
123
+ },
124
+ ];
125
+
126
+ const { result } = renderHook( () =>
127
+ useZeroValueDisplay( data, { enabled: true, valueAxisLength: 100 } )
128
+ );
129
+
130
+ const zeroVisual = ( result.current[ 0 ].data[ 0 ] as { visualValue?: number } ).visualValue;
131
+ const nearZeroVisual = ( result.current[ 0 ].data[ 1 ] as { visualValue?: number } )
132
+ .visualValue;
133
+
134
+ // Zero = 2px equivalent (2), near-zero = 3px equivalent (3)
135
+ expect( zeroVisual ).toBe( 2 );
136
+ expect( nearZeroVisual ).toBe( 3 );
137
+ expect( nearZeroVisual! - zeroVisual! ).toBe( 1 ); // 1px difference
138
+ } );
139
+
140
+ test( 'handles data with only zero values', () => {
141
+ const zeroOnlyData: SeriesData[] = [
142
+ {
143
+ label: 'Series 1',
144
+ data: [
145
+ { label: 'A', value: 0 },
146
+ { label: 'B', value: 0 },
147
+ ],
148
+ },
149
+ ];
150
+
151
+ const { result } = renderHook( () =>
152
+ useZeroValueDisplay( zeroOnlyData, { enabled: true, valueAxisLength: 100 } )
153
+ );
154
+
155
+ // Should return original data since there are no non-zero values to calculate from
156
+ expect( result.current ).toBe( zeroOnlyData );
157
+ } );
158
+
159
+ test( 'negative near-zero values preserve sign when boosted', () => {
160
+ const data: SeriesData[] = [
161
+ {
162
+ label: 'Series 1',
163
+ data: [
164
+ { label: 'NegativeNearZero', value: -1 }, // Would render as 1px (below 3px minimum)
165
+ { label: 'NegativeLarge', value: -100 },
166
+ { label: 'PositiveMax', value: 100 },
167
+ ],
168
+ },
169
+ ];
170
+
171
+ const { result } = renderHook( () =>
172
+ useZeroValueDisplay( data, { enabled: true, valueAxisLength: 100 } )
173
+ );
174
+
175
+ const negativeNearZero = result.current[ 0 ].data[ 0 ] as { visualValue?: number };
176
+
177
+ // Should have a boosted magnitude but keep the negative sign
178
+ expect( negativeNearZero.visualValue ).toBeLessThan( 0 );
179
+ expect( Math.abs( negativeNearZero.visualValue! ) ).toBeGreaterThan( 1 );
180
+ } );
181
+
182
+ test( 'null values remain untouched without visualValue', () => {
183
+ const data: SeriesData[] = [
184
+ {
185
+ label: 'Series 1',
186
+ data: [
187
+ { label: 'NullValue', value: null },
188
+ { label: 'NonZero', value: 50 },
189
+ ],
190
+ },
191
+ ];
192
+
193
+ const { result } = renderHook( () =>
194
+ useZeroValueDisplay( data, { enabled: true, valueAxisLength: 100 } )
195
+ );
196
+
197
+ const nullPoint = result.current[ 0 ].data[ 0 ] as {
198
+ value: number | null;
199
+ visualValue?: number;
200
+ };
201
+
202
+ // The original null value should be preserved and no visualValue should be added
203
+ expect( nullPoint.value ).toBeNull();
204
+ expect( nullPoint ).not.toHaveProperty( 'visualValue' );
205
+ } );
206
+ } );
@@ -11,57 +11,86 @@ export interface EnhancedSeriesData extends Omit< SeriesData, 'data' > {
11
11
 
12
12
  export interface UseZeroValueDisplayOptions {
13
13
  enabled: boolean;
14
- minValueRatio?: number;
15
- maxValueRatio?: number;
14
+ /**
15
+ * The pixel length of the value axis (height for vertical charts, width for
16
+ * horizontal charts). Used to calculate a minimum visible value that ensures
17
+ * zero-value bars are at least MIN_PIXEL_HEIGHT pixels tall along that axis.
18
+ */
19
+ valueAxisLength?: number;
16
20
  }
17
21
 
22
+ /**
23
+ * Minimum pixel size for near-zero bars (non-zero values that would render too small).
24
+ * Using 3px to be visible but not misleading - larger values might look like actual data.
25
+ */
26
+ const MIN_PIXEL_SIZE = 3;
27
+
28
+ /**
29
+ * Pixel size for zero-value bars (1px less than near-zero to be visually distinguishable).
30
+ */
31
+ const ZERO_PIXEL_SIZE = MIN_PIXEL_SIZE - 1;
32
+
18
33
  export const useZeroValueDisplay = (
19
34
  data: SeriesData[],
20
35
  options: UseZeroValueDisplayOptions = { enabled: false }
21
36
  ): SeriesData[] | EnhancedSeriesData[] => {
22
- const { enabled, minValueRatio = 0.6, maxValueRatio = 0.008 } = options;
37
+ const { enabled, valueAxisLength } = options;
23
38
 
24
39
  return useMemo( () => {
25
- if ( ! enabled ) return data;
26
-
27
- // Collect all non-zero, non-null values (both positive and negative)
28
- const nonZeroValues: number[] = [];
40
+ if ( ! enabled || ! valueAxisLength || valueAxisLength <= 0 ) return data;
29
41
 
42
+ // Find max absolute value
43
+ let maxAbsoluteValue = 0;
30
44
  for ( const series of data ) {
31
45
  for ( const point of series.data ) {
32
46
  if ( point.value !== null && point.value !== 0 ) {
33
- nonZeroValues.push( point.value );
47
+ maxAbsoluteValue = Math.max( maxAbsoluteValue, Math.abs( point.value ) );
34
48
  }
35
49
  }
36
50
  }
37
51
 
38
- if ( nonZeroValues.length === 0 ) return data;
39
-
40
- // Convert to absolute values to find the range
41
- const absoluteValues = nonZeroValues.map( Math.abs );
52
+ if ( maxAbsoluteValue === 0 ) return data;
42
53
 
43
- // Calculate min and max based on absolute values
44
- const minAbsoluteValue = Math.min( ...absoluteValues );
45
- const maxAbsoluteValue = Math.max( ...absoluteValues );
46
-
47
- // Calculate minimum visible value using absolute range
48
- const minVisibleValue = Math.min(
49
- minAbsoluteValue * minValueRatio,
50
- maxAbsoluteValue * maxValueRatio
54
+ // Calculate values that render as specific pixel sizes, clamped to maxAbsoluteValue
55
+ // to prevent visual distortion when valueAxisLength is very small
56
+ const minNonZeroValue = Math.min(
57
+ ( MIN_PIXEL_SIZE / valueAxisLength ) * maxAbsoluteValue,
58
+ maxAbsoluteValue
59
+ );
60
+ const zeroVisualValue = Math.min(
61
+ ( ZERO_PIXEL_SIZE / valueAxisLength ) * maxAbsoluteValue,
62
+ maxAbsoluteValue
51
63
  );
52
64
 
53
65
  return data.map( series => ( {
54
66
  ...series,
55
- data: series.data.map( ( point ): EnhancedDataPoint => {
67
+ data: series.data.map( ( point: DataPointDate ): EnhancedDataPoint => {
68
+ // Zero values get a smaller visual representation (2px)
56
69
  if ( point.value === 0 ) {
57
70
  return {
58
71
  ...point,
59
- visualValue: minVisibleValue,
72
+ visualValue: zeroVisualValue,
73
+ };
74
+ }
75
+
76
+ // Null values should remain untouched
77
+ if ( point.value === null ) {
78
+ return point;
79
+ }
80
+
81
+ const absValue = Math.abs( point.value );
82
+
83
+ // Near-zero values that would render below MIN_PIXEL_SIZE get boosted to 3px
84
+ // Preserve the sign for negative values
85
+ if ( absValue < minNonZeroValue ) {
86
+ return {
87
+ ...point,
88
+ visualValue: Math.sign( point.value ) * minNonZeroValue,
60
89
  };
61
90
  }
62
91
 
63
92
  return point;
64
93
  } ),
65
94
  } ) );
66
- }, [ data, enabled, minValueRatio, maxValueRatio ] );
95
+ }, [ data, enabled, valueAxisLength ] );
67
96
  };
@@ -987,10 +987,12 @@ describe( 'ChartContext', () => {
987
987
 
988
988
  const extendedTheme = {
989
989
  ...mockTheme,
990
- legendShapeStyles: [
991
- { fill: '#LEGEND1', stroke: '#BORDER1' },
992
- { fill: '#LEGEND2', strokeWidth: 3 },
993
- ],
990
+ legend: {
991
+ shapeStyles: [
992
+ { fill: '#LEGEND1', stroke: '#BORDER1' },
993
+ { fill: '#LEGEND2', strokeWidth: 3 },
994
+ ],
995
+ },
994
996
  };
995
997
 
996
998
  render(
@@ -1087,7 +1089,9 @@ describe( 'ChartContext', () => {
1087
1089
  },
1088
1090
  },
1089
1091
  },
1090
- legendShapeStyles: [ { fill: '#LEGEND1', stroke: '#BORDER1' } ],
1092
+ legend: {
1093
+ shapeStyles: [ { fill: '#LEGEND1', stroke: '#BORDER1' } ],
1094
+ },
1091
1095
  };
1092
1096
 
1093
1097
  render(
@@ -1234,7 +1238,9 @@ describe( 'ChartContext', () => {
1234
1238
  ...mockTheme,
1235
1239
  glyphs: [ mockGlyph ],
1236
1240
  seriesLineStyles: [ { strokeWidth: 2 } ],
1237
- legendShapeStyles: [ { fill: '#SHAPE1' } ],
1241
+ legend: {
1242
+ shapeStyles: [ { fill: '#SHAPE1' } ],
1243
+ },
1238
1244
  };
1239
1245
 
1240
1246
  render(
@@ -17,12 +17,14 @@ const defaultTheme: CompleteChartTheme = {
17
17
  gridColorDark: '',
18
18
  xTickLineStyles: { stroke: 'black' },
19
19
  xAxisLineStyles: { stroke: '#DCDCDE', strokeWidth: 1 },
20
- legendLabelStyles: {
21
- color: 'var(--jp-gray-80, #2c3338)',
20
+ legend: {
21
+ labelStyles: {
22
+ color: 'var(--jp-gray-80, #2c3338)',
23
+ },
24
+ containerStyles: {},
25
+ shapeStyles: [],
22
26
  },
23
- legendContainerStyles: {},
24
27
  seriesLineStyles: [],
25
- legendShapeStyles: [],
26
28
  glyphs: [],
27
29
  svgLabelSmall: { fill: 'var(--jp-gray-80, #2c3338)' },
28
30
  annotationStyles: {
package/src/types.ts CHANGED
@@ -210,14 +210,17 @@ export type ChartTheme = {
210
210
  xAxisLineStyles?: LineStyles;
211
211
  /** Styles for series lines */
212
212
  seriesLineStyles?: LineStyles[];
213
- /** Styles for legend shapes */
214
- legendShapeStyles?: Record< string, unknown >[];
215
213
  /** Array of render functions for glyphs */
216
214
  glyphs?: Array< < Datum extends object >( props: GlyphProps< Datum > ) => ReactNode >;
217
- /** Styles for legend labels */
218
- legendLabelStyles?: CSSProperties;
219
- /** Styles for legend container */
220
- legendContainerStyles?: CSSProperties;
215
+ /** Legend specific settings */
216
+ legend?: {
217
+ /** Styles for legend shapes */
218
+ shapeStyles?: Record< string, unknown >[];
219
+ /** Styles for legend labels */
220
+ labelStyles?: CSSProperties;
221
+ /** Styles for legend container */
222
+ containerStyles?: CSSProperties;
223
+ };
221
224
  /** Styles for small SVG text (eg. axis tick labels), passed through to the XYChart theme. */
222
225
  svgLabelSmall?: TextProps;
223
226
  annotationStyles?: AnnotationStyles;
@@ -287,6 +290,7 @@ export type CompleteChartTheme = Required< ChartTheme > & {
287
290
  lineChart: {
288
291
  lineStyles: Record< NonNullable< SeriesDataOptions[ 'type' ] >, LineStyles >;
289
292
  };
293
+ legend: Required< NonNullable< ChartTheme[ 'legend' ] > >;
290
294
  sparkline: Required< NonNullable< ChartTheme[ 'sparkline' ] > > & {
291
295
  margin: Required< NonNullable< ChartTheme[ 'sparkline' ] >[ 'margin' ] >;
292
296
  };
@@ -348,6 +352,86 @@ export type ScaleOptions = {
348
352
  paddingOuter?: number;
349
353
  };
350
354
 
355
+ export type LegendItemStyles = {
356
+ /** Margin around each legend item. */
357
+ margin?: CSSProperties[ 'margin' ];
358
+ /** Flex direction for items within each legend entry. */
359
+ flexDirection?: 'row' | 'row-reverse' | 'column' | 'column-reverse';
360
+ };
361
+
362
+ export type LegendLabelStyles = Pick< CSSProperties, 'justifyContent' | 'flex' | 'margin' > & {
363
+ /**
364
+ * Maximum width for legend label text as a CSS value (e.g. '200px', '50%', '10rem').
365
+ * When set, text overflow behavior is controlled by textOverflow.
366
+ */
367
+ maxWidth?: string;
368
+ /**
369
+ * Controls how text behaves when it exceeds maxWidth.
370
+ * - 'ellipsis': Truncate with ellipsis (ideal for widgets/small devices)
371
+ * - 'wrap': Wrap text to multiple lines (default, ideal for larger displays)
372
+ */
373
+ textOverflow?: 'ellipsis' | 'wrap';
374
+ };
375
+
376
+ export type LegendShapeStyles = {
377
+ /** Width of the legend shape in pixels. */
378
+ width?: number;
379
+ /** Height of the legend shape in pixels. */
380
+ height?: number;
381
+ /** Margin around the legend shape. */
382
+ margin?: CSSProperties[ 'margin' ];
383
+ };
384
+
385
+ /** Position of the legend relative to chart content. */
386
+ export type LegendPosition = 'top' | 'bottom';
387
+
388
+ /**
389
+ * Configuration object for chart legend appearance and behavior.
390
+ * Consolidates all legend styling and layout props into a single structured object.
391
+ */
392
+ export type ChartLegendConfig< T = DataPoint | DataPointDate | LeaderboardEntry > = {
393
+ /**
394
+ * Layout direction of legend items.
395
+ */
396
+ orientation?: 'horizontal' | 'vertical';
397
+ /**
398
+ * Position of the legend relative to the chart.
399
+ * TODO: Add 'left' | 'right' positioning support in future implementation
400
+ */
401
+ position?: LegendPosition;
402
+ /**
403
+ * Alignment of the legend within its position.
404
+ */
405
+ alignment?: 'start' | 'center' | 'end';
406
+ /**
407
+ * Shape of the legend marker icon.
408
+ */
409
+ shape?: LegendShape< T, number >;
410
+ /**
411
+ * Enable interactive legend items that can toggle series visibility.
412
+ * Supported for all chart types that render series.
413
+ * Requires chartId and GlobalChartsProvider.
414
+ * For pie charts, percentages are recalculated so visible segments total 100%.
415
+ */
416
+ interactive?: boolean;
417
+ /**
418
+ * Additional CSS class name for individual legend items.
419
+ */
420
+ itemClassName?: string;
421
+ /**
422
+ * CSS styles for each legend item (margin, flexDirection).
423
+ */
424
+ itemStyles?: LegendItemStyles;
425
+ /**
426
+ * CSS styles for legend labels (maxWidth, textOverflow, justifyContent, flex, margin).
427
+ */
428
+ labelStyles?: LegendLabelStyles;
429
+ /**
430
+ * Styles for legend shapes (width, height, margin).
431
+ */
432
+ shapeStyles?: LegendShapeStyles;
433
+ };
434
+
351
435
  /**
352
436
  * Base properties shared across all chart components
353
437
  */
@@ -412,45 +496,10 @@ export type BaseChartProps< T = DataPoint | DataPointDate | LeaderboardEntry > =
412
496
  */
413
497
  showLegend?: boolean;
414
498
  /**
415
- * Legend orientation
416
- */
417
- legendOrientation?: 'horizontal' | 'vertical';
418
- /**
419
- * Legend shape
420
- */
421
- legendShape?: LegendShape< T, number >;
422
- /**
423
- * Legend position (where the legend appears)
424
- * TODO: Add 'left' | 'right' positioning support in future implementation
425
- */
426
- legendPosition?: 'top' | 'bottom';
427
- /**
428
- * Legend alignment within its position
429
- */
430
- legendAlignment?: 'start' | 'center' | 'end';
431
- /**
432
- * Maximum width for legend items. When set, text overflow behavior is controlled by legendTextOverflow.
433
- * Should be a CSS value string (e.g. '200px', '50%', '10rem')
434
- */
435
- legendMaxWidth?: string;
436
- /**
437
- * Controls how text behaves when it exceeds legendMaxWidth.
438
- * - 'ellipsis': Truncate with ellipsis (ideal for widgets/small devices)
439
- * - 'wrap': Wrap text to multiple lines (default, ideal for larger displays)
440
- */
441
- legendTextOverflow?: 'ellipsis' | 'wrap';
442
- /**
443
- * Additional CSS class name for legend items.
444
- * This allows consumers to customize individual legend item styling.
445
- */
446
- legendItemClassName?: string;
447
- /**
448
- * Enable interactive legend items that can toggle series visibility.
449
- * Supported for LineChart, PieChart, and PieSemiCircleChart.
450
- * Requires chartId and GlobalChartsProvider.
451
- * For pie charts, percentages are recalculated so visible segments total 100%.
499
+ * Legend configuration object for controlling legend appearance and behavior.
500
+ * Includes orientation, position, alignment, shape, styling, and interactivity options.
452
501
  */
453
- legendInteractive?: boolean;
502
+ legend?: ChartLegendConfig< T >;
454
503
  /**
455
504
  * Grid visibility. x is default when orientation is vertical. y is default when orientation is horizontal.
456
505
  */
@@ -44,7 +44,16 @@ import { parse, parseISO, isValid } from 'date-fns';
44
44
  * @return {boolean} True if the date string contains timezone information, false otherwise
45
45
  */
46
46
  const hasTimezone = ( dateString: string ): boolean => {
47
- return /T.*[Z]$|T.*[+-]\d{2}:?\d{2}$/.test( dateString );
47
+ const tIndex = dateString.indexOf( 'T' );
48
+ if ( tIndex === -1 ) {
49
+ return false;
50
+ }
51
+
52
+ if ( dateString.endsWith( 'Z' ) ) {
53
+ return true;
54
+ }
55
+
56
+ return /[+-]\d{2}:?\d{2}$/.test( dateString.slice( tIndex + 1 ) );
48
57
  };
49
58
 
50
59
  /**
@@ -62,7 +62,7 @@ export function getItemShapeStyles(
62
62
  ): Record< string, unknown > {
63
63
  const seriesShapeStyles = series.options?.legendShapeStyle ?? {};
64
64
  const lineStyles = legendShape === 'line' ? getSeriesLineStyles( series, index, theme ) : {};
65
- const themeShapeStyles = theme.legendShapeStyles?.[ index ];
65
+ const themeShapeStyles = theme.legend?.shapeStyles?.[ index ];
66
66
 
67
67
  const itemShapeStyles = {
68
68
  ...seriesShapeStyles,
@@ -268,6 +268,18 @@ describe( 'parseAsLocalDate', () => {
268
268
  } );
269
269
  } );
270
270
 
271
+ describe( 'ReDoS resilience', () => {
272
+ test( 'should handle adversarial input without catastrophic backtracking', () => {
273
+ const malicious = 'T' + 'a'.repeat( 8000000 ) + 'X';
274
+ const start = performance.now();
275
+ const result = parseAsLocalDate( malicious );
276
+ const elapsed = performance.now() - start;
277
+
278
+ expect( isNaN( result.getTime() ) ).toBe( true );
279
+ expect( elapsed ).toBeLessThan( 50 );
280
+ } );
281
+ } );
282
+
271
283
  describe( 'Performance and consistency', () => {
272
284
  test( 'should consistently parse the same input', () => {
273
285
  const dateString = '2025-01-15T14:30:45Z';