@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,11 +1,8 @@
1
1
  /* eslint-disable @wordpress/no-unsafe-wp-apis */
2
- import {
3
- __experimentalVStack as VStack,
4
- __experimentalGrid as Grid,
5
- __experimentalText as Text,
6
- } from '@wordpress/components';
2
+ import { __experimentalGrid as Grid, __experimentalText as Text } from '@wordpress/components';
7
3
  import { Fragment } from '@wordpress/element';
8
4
  import { __ } from '@wordpress/i18n';
5
+ import { Stack } from '@wordpress/ui';
9
6
  import clsx from 'clsx';
10
7
  import { useContext, useMemo, type FC } from 'react';
11
8
  import { Legend } from '../../components/legend';
@@ -19,7 +16,7 @@ import {
19
16
  useGlobalChartsTheme,
20
17
  } from '../../providers';
21
18
  import { formatMetricValue, attachSubComponents } from '../../utils';
22
- import { useChartChildren } from '../private/chart-composition';
19
+ import { useChartChildren, renderLegendSlot } from '../private/chart-composition';
23
20
  import { SingleChartContext } from '../private/single-chart-context';
24
21
  import { withResponsive } from '../private/with-responsive';
25
22
  import { useLeaderboardLegendItems } from './hooks';
@@ -113,34 +110,33 @@ const BarWithLabel = ( {
113
110
  * LeaderboardChart component displays a ranked list of data with progress bars
114
111
  * and optional comparison values.
115
112
  *
116
- * @param props - Component props
117
- * @param props.data - Array of leaderboard entries to display
118
- * @param props.chartId - Optional unique identifier for the chart
119
- * @param props.withComparison - Whether to show comparison data
120
- * @param props.withOverlayLabel - Whether to overlay the label on top of the bar
121
- * @param props.primaryColor - Primary color for current period bars
122
- * @param props.secondaryColor - Secondary color for comparison period bars
123
- * @param props.valueFormatter - Custom formatter for values
124
- * @param props.deltaFormatter - Custom formatter for delta values
125
- * @param props.loading - Whether the chart is in loading state
126
- * @param props.animation - Whether the chart should animate on load
127
- * @param props.showLegend - Whether to show legend
128
- * @param props.legendOrientation - Legend orientation
129
- * @param props.legendPosition - Legend position
130
- * @param props.legendAlignment - Legend alignment
131
- * @param props.legendShape - Legend shape
132
- * @param props.legendShapeWidth - Width of legend shapes in pixels
133
- * @param props.legendShapeHeight - Height of legend shapes in pixels
134
- * @param props.legendLabels - Custom labels for legend items
135
- * @param props.legendInteractive - Whether legend items are interactive (clickable to toggle series visibility)
136
- * @param props.children - Child components for composition API
137
- * @param props.className - Additional CSS class name
138
- * @param props.style - Custom styling for the chart container
113
+ * @param props - Component props
114
+ * @param props.data - Array of leaderboard entries to display
115
+ * @param props.chartId - Optional unique identifier for the chart
116
+ * @param props.width - Optional width of the chart container in pixels
117
+ * @param props.height - Optional height of the chart container in pixels
118
+ * @param props.withComparison - Whether to show comparison data
119
+ * @param props.withOverlayLabel - Whether to overlay the label on top of the bar
120
+ * @param props.primaryColor - Primary color for current period bars
121
+ * @param props.secondaryColor - Secondary color for comparison period bars
122
+ * @param props.valueFormatter - Custom formatter for values
123
+ * @param props.deltaFormatter - Custom formatter for delta values
124
+ * @param props.loading - Whether the chart is in loading state
125
+ * @param props.animation - Whether the chart should animate on load
126
+ * @param props.showLegend - Whether to show legend
127
+ * @param props.legend - Legend configuration (orientation, position, alignment, shape, shapeStyles, interactive)
128
+ * @param props.legendLabels - Custom labels for legend items
129
+ * @param props.gap - Spacing between legend and chart content
130
+ * @param props.children - Child components for composition API
131
+ * @param props.className - Additional CSS class name
132
+ * @param props.style - Custom styling for the chart container
139
133
  * @return JSX element representing the leaderboard chart
140
134
  */
141
135
  const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
142
136
  data,
143
137
  chartId: providedChartId,
138
+ width: propWidth,
139
+ height: propHeight,
144
140
  withComparison = false,
145
141
  withOverlayLabel = false,
146
142
  primaryColor,
@@ -150,23 +146,22 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
150
146
  animation,
151
147
  loading = false,
152
148
  showLegend = false,
153
- legendOrientation = 'horizontal',
154
- legendPosition = 'bottom',
155
- legendAlignment = 'center',
156
- legendShape = 'circle',
157
- legendShapeWidth = 8,
158
- legendShapeHeight = 8,
149
+ legend = {},
159
150
  legendLabels,
160
- legendInteractive = false,
151
+ gap = 'md',
161
152
  className,
162
153
  style,
163
154
  children,
164
155
  } ) => {
156
+ const legendInteractive = legend.interactive ?? false;
157
+ const legendPosition = legend.position ?? 'bottom';
158
+
165
159
  const chartId = useChartId( providedChartId );
166
160
  const { leaderboardChart: leaderboardChartSettings } = useGlobalChartsTheme();
161
+ const legendShapeStyles = { width: 8, height: 8, ...legend.shapeStyles };
167
162
 
168
163
  // Process children to extract compound components
169
- const { otherChildren } = useChartChildren( children, 'LeaderboardChart' );
164
+ const { legendChildren, nonLegendChildren } = useChartChildren( children, 'LeaderboardChart' );
170
165
  const {
171
166
  labelSpacing,
172
167
  rowGap,
@@ -258,26 +253,49 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
258
253
  chartHeight: 0,
259
254
  } }
260
255
  >
261
- <div
256
+ <Stack
257
+ direction="column"
258
+ data-testid="leaderboard-chart-container"
262
259
  className={ clsx(
263
260
  styles.leaderboardChart,
261
+ { [ styles[ 'leaderboardChart--responsive' ] ]: ! propWidth && ! propHeight },
264
262
  { [ styles[ 'leaderboardChart--loading' ] ]: loading },
265
263
  className
266
264
  ) }
267
- style={ style }
265
+ gap={ gap }
266
+ style={ {
267
+ ...style,
268
+ width: propWidth || undefined,
269
+ height: propHeight || undefined,
270
+ } }
268
271
  >
269
272
  <div className={ styles.emptyState }>
270
273
  { loading
271
274
  ? __( 'Loading…', 'jetpack-charts' )
272
275
  : __( 'No data available', 'jetpack-charts' ) }
273
276
  </div>
274
- { /* Render children from composition API */ }
275
- { otherChildren }
276
- </div>
277
+
278
+ { nonLegendChildren }
279
+ </Stack>
277
280
  </SingleChartContext.Provider>
278
281
  );
279
282
  }
280
283
 
284
+ const legendElement = showLegend && (
285
+ <Legend
286
+ orientation={ legend.orientation ?? 'horizontal' }
287
+ position={ legendPosition }
288
+ alignment={ legend.alignment ?? 'center' }
289
+ labelStyles={ legend.labelStyles }
290
+ itemClassName={ legend.itemClassName }
291
+ itemStyles={ legend.itemStyles }
292
+ shape={ legend.shape ?? 'circle' }
293
+ shapeStyles={ legendShapeStyles }
294
+ chartId={ chartId }
295
+ interactive={ legendInteractive }
296
+ />
297
+ );
298
+
281
299
  return (
282
300
  <SingleChartContext.Provider
283
301
  value={ {
@@ -286,76 +304,80 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
286
304
  chartHeight: 0,
287
305
  } }
288
306
  >
289
- <div
307
+ <Stack
308
+ direction="column"
309
+ data-testid="leaderboard-chart-container"
290
310
  className={ clsx(
291
311
  styles.leaderboardChart,
292
312
  {
313
+ [ styles[ 'leaderboardChart--responsive' ] ]: ! propWidth && ! propHeight,
293
314
  [ styles[ 'leaderboardChart--loading' ] ]: loading,
294
- [ styles[ 'leaderboardChart--with-legend' ] ]: showLegend,
295
- [ styles[ 'leaderboardChart--legend-top' ] ]: showLegend && legendPosition === 'top',
296
315
  },
297
316
  className
298
317
  ) }
299
- style={ style }
318
+ gap={ gap }
319
+ style={ {
320
+ ...style,
321
+ width: propWidth || undefined,
322
+ height: propHeight || undefined,
323
+ } }
300
324
  >
301
- { allSeriesHidden ? (
302
- <div className={ styles.emptyState }>
303
- { __( 'All series are hidden. Click legend items to show data.', 'jetpack-charts' ) }
304
- </div>
305
- ) : (
306
- <Grid templateColumns="minmax(0, 1fr) auto" rowGap={ rowGap } columnGap={ columnGap }>
307
- { data.map( entry => {
308
- const colorIndex = Math.sign( entry.delta ) + 1;
309
- const deltaColor = deltaColors[ colorIndex ];
310
-
311
- return (
312
- <Fragment key={ entry.id }>
313
- <VStack spacing={ labelSpacing }>
314
- <BarWithLabel
315
- entry={ entry }
316
- withComparison={ withComparison }
317
- withOverlayLabel={ withOverlayLabel }
318
- primaryColor={ resolvedPrimaryColor }
319
- secondaryColor={ resolvedSecondaryColor }
320
- isPrimaryVisible={ isPrimaryVisible }
321
- isComparisonVisible={ isComparisonVisible }
322
- animation={ animation && ! loading && ! prefersReducedMotion }
323
- />
324
- </VStack>
325
-
326
- <div
327
- className={ clsx( styles.valueContainer, {
328
- [ styles.overlayLabel ]: withOverlayLabel,
329
- } ) }
330
- >
331
- { isPrimaryVisible && <Text>{ valueFormatter( entry.currentValue ) }</Text> }
332
-
333
- { withComparison && isComparisonVisible && (
334
- <Text style={ { color: deltaColor } }>{ deltaFormatter( entry.delta ) }</Text>
335
- ) }
336
- </div>
337
- </Fragment>
338
- );
339
- } ) }
340
- </Grid>
341
- ) }
325
+ { legendPosition === 'top' && legendElement }
326
+ { renderLegendSlot( legendChildren, 'top' ) }
342
327
 
343
- { showLegend && (
344
- <Legend
345
- orientation={ legendOrientation }
346
- position={ legendPosition }
347
- alignment={ legendAlignment }
348
- shape={ legendShape }
349
- shapeWidth={ legendShapeWidth }
350
- shapeHeight={ legendShapeHeight }
351
- chartId={ chartId }
352
- interactive={ legendInteractive }
353
- />
354
- ) }
328
+ <div className={ styles.leaderboardChart__content }>
329
+ { allSeriesHidden ? (
330
+ <div className={ styles.emptyState }>
331
+ { __( 'All series are hidden. Click legend items to show data.', 'jetpack-charts' ) }
332
+ </div>
333
+ ) : (
334
+ <Grid templateColumns="minmax(0, 1fr) auto" rowGap={ rowGap } columnGap={ columnGap }>
335
+ { data.map( entry => {
336
+ const colorIndex = Math.sign( entry.delta ) + 1;
337
+ const deltaColor = deltaColors[ colorIndex ];
338
+
339
+ return (
340
+ <Fragment key={ entry.id }>
341
+ <Stack direction="column" gap={ labelSpacing }>
342
+ <BarWithLabel
343
+ entry={ entry }
344
+ withComparison={ withComparison }
345
+ withOverlayLabel={ withOverlayLabel }
346
+ primaryColor={ resolvedPrimaryColor }
347
+ secondaryColor={ resolvedSecondaryColor }
348
+ isPrimaryVisible={ isPrimaryVisible }
349
+ isComparisonVisible={ isComparisonVisible }
350
+ animation={ animation && ! loading && ! prefersReducedMotion }
351
+ />
352
+ </Stack>
353
+
354
+ <Stack
355
+ direction="row"
356
+ gap="xs"
357
+ className={ clsx( styles.valueContainer, {
358
+ [ styles.overlayLabel ]: withOverlayLabel,
359
+ } ) }
360
+ >
361
+ { isPrimaryVisible && <Text>{ valueFormatter( entry.currentValue ) }</Text> }
362
+
363
+ { withComparison && isComparisonVisible && (
364
+ <Text style={ { color: deltaColor } }>
365
+ { deltaFormatter( entry.delta ) }
366
+ </Text>
367
+ ) }
368
+ </Stack>
369
+ </Fragment>
370
+ );
371
+ } ) }
372
+ </Grid>
373
+ ) }
374
+ </div>
375
+
376
+ { legendPosition === 'bottom' && legendElement }
377
+ { renderLegendSlot( legendChildren, 'bottom' ) }
355
378
 
356
- { /* Render children from composition API */ }
357
- { otherChildren }
358
- </div>
379
+ { nonLegendChildren }
380
+ </Stack>
359
381
  </SingleChartContext.Provider>
360
382
  );
361
383
  };
@@ -2,6 +2,17 @@ import { render, screen } from '@testing-library/react';
2
2
  import LeaderboardChart from '../leaderboard-chart';
3
3
  import type { LeaderboardEntry } from '../../../types';
4
4
 
5
+ const mockDefaultParentSize = () => ( {
6
+ parentRef: { current: null },
7
+ width: 400,
8
+ height: 300,
9
+ } );
10
+
11
+ // Mock useParentSize so the responsive wrapper returns predictable dimensions in tests
12
+ jest.mock( '@visx/responsive', () => ( {
13
+ useParentSize: jest.fn( () => mockDefaultParentSize() ),
14
+ } ) );
15
+
5
16
  const mockData: LeaderboardEntry[] = [
6
17
  {
7
18
  id: 'direct',
@@ -40,6 +51,11 @@ const testValueFormatter = ( value: number ) => `${ value }$`;
40
51
  const testDeltaFormatter = ( value: number ) => `${ value }delta`;
41
52
 
42
53
  describe( 'LeaderboardChart', () => {
54
+ afterEach( () => {
55
+ const { useParentSize } = jest.requireMock( '@visx/responsive' );
56
+ useParentSize.mockImplementation( () => mockDefaultParentSize() );
57
+ } );
58
+
43
59
  it( 'renders leaderboard entries', () => {
44
60
  render( <LeaderboardChart data={ mockData } /> );
45
61
 
@@ -153,9 +169,7 @@ describe( 'LeaderboardChart', () => {
153
169
  data={ mockData }
154
170
  withComparison={ true }
155
171
  showLegend={ true }
156
- legendShape="rect"
157
- legendShapeWidth={ 10 }
158
- legendShapeHeight={ 6 }
172
+ legend={ { shape: 'rect', shapeStyles: { width: 10, height: 6 } } }
159
173
  />
160
174
  );
161
175
 
@@ -204,7 +218,7 @@ describe( 'LeaderboardChart', () => {
204
218
  it( 'renders LeaderboardChart.Legend as child component', () => {
205
219
  render(
206
220
  <LeaderboardChart data={ mockData } withComparison={ true }>
207
- <LeaderboardChart.Legend data-testid="composition-legend-item" />
221
+ <LeaderboardChart.Legend />
208
222
  </LeaderboardChart>
209
223
  );
210
224
 
@@ -212,8 +226,8 @@ describe( 'LeaderboardChart', () => {
212
226
  expect( screen.getByText( 'Direct' ) ).toBeInTheDocument();
213
227
  expect( screen.getByText( 'Social Media' ) ).toBeInTheDocument();
214
228
 
215
- // Composition legend should render - each legend item gets its own element
216
- expect( screen.getAllByTestId( 'composition-legend-item' ) ).toHaveLength( 2 );
229
+ // Composition legend should render
230
+ expect( screen.getAllByTestId( 'legend-item' ) ).toHaveLength( 2 );
217
231
  expect( screen.getByText( 'Current period' ) ).toBeInTheDocument();
218
232
  expect( screen.getByText( 'Previous period' ) ).toBeInTheDocument();
219
233
  } );
@@ -221,15 +235,12 @@ describe( 'LeaderboardChart', () => {
221
235
  it( 'renders composition legend regardless of showLegend value', () => {
222
236
  render(
223
237
  <LeaderboardChart data={ mockData } withComparison={ true } showLegend={ false }>
224
- <LeaderboardChart.Legend data-testid="composition-legend-item" />
238
+ <LeaderboardChart.Legend />
225
239
  </LeaderboardChart>
226
240
  );
227
241
 
228
- // No built-in legend should be rendered when showLegend is false
229
- expect( screen.queryByTestId( 'legend-item' ) ).not.toBeInTheDocument();
230
-
231
- // Composition legend should still render regardless of showLegend value
232
- expect( screen.getAllByTestId( 'composition-legend-item' ) ).toHaveLength( 2 );
242
+ // Composition legend should render regardless of showLegend value
243
+ expect( screen.getAllByTestId( 'legend-item' ) ).toHaveLength( 2 );
233
244
  expect( screen.getByText( 'Current period' ) ).toBeInTheDocument();
234
245
  expect( screen.getByText( 'Previous period' ) ).toBeInTheDocument();
235
246
  } );
@@ -237,41 +248,36 @@ describe( 'LeaderboardChart', () => {
237
248
  it( 'supports both built-in and composition legends simultaneously', () => {
238
249
  render(
239
250
  <LeaderboardChart data={ mockData } withComparison={ true } showLegend={ true }>
240
- <LeaderboardChart.Legend data-testid="composition-legend-item" />
251
+ <LeaderboardChart.Legend />
241
252
  </LeaderboardChart>
242
253
  );
243
254
 
244
- // Built-in legend should render (with legend-item test IDs)
245
- expect( screen.getAllByTestId( 'legend-item' ) ).toHaveLength( 2 );
246
-
247
- // Composition legend should also render
248
- expect( screen.getAllByTestId( 'composition-legend-item' ) ).toHaveLength( 2 );
255
+ // Both built-in and composition legends should render (2 items each = 4 total)
256
+ expect( screen.getAllByTestId( 'legend-item' ) ).toHaveLength( 4 );
249
257
 
250
258
  // Should have legend items from both legends
251
259
  const currentPeriodItems = screen.getAllByText( 'Current period' );
252
260
  const previousPeriodItems = screen.getAllByText( 'Previous period' );
253
- expect( currentPeriodItems ).toHaveLength( 2 ); // One from each legend
254
- expect( previousPeriodItems ).toHaveLength( 2 ); // One from each legend
261
+ expect( currentPeriodItems ).toHaveLength( 2 );
262
+ expect( previousPeriodItems ).toHaveLength( 2 );
255
263
  } );
256
264
 
257
265
  it( 'passes props correctly to composition legend', () => {
258
266
  render(
259
267
  <LeaderboardChart data={ mockData } withComparison={ true }>
260
- <LeaderboardChart.Legend
261
- data-testid="composition-legend-item"
262
- shape="circle"
263
- shapeWidth={ 12 }
264
- shapeHeight={ 12 }
265
- style={ { marginTop: '20px' } }
266
- />
268
+ <LeaderboardChart.Legend shape="circle" shapeStyles={ { margin: '4px 8px' } } />
267
269
  </LeaderboardChart>
268
270
  );
269
271
 
270
- const legendItems = screen.getAllByTestId( 'composition-legend-item' );
272
+ const legendItems = screen.getAllByTestId( 'legend-item' );
271
273
  expect( legendItems ).toHaveLength( 2 );
272
- // Check that each legend item has the custom style applied
274
+
275
+ // Verify custom shape styles are applied within each legend item.
276
+ // Direct DOM access is needed because visx legend shapes lack accessible attributes and we cannot pass a test id to them.
273
277
  legendItems.forEach( item => {
274
- expect( item ).toHaveStyle( { marginTop: '20px' } );
278
+ // eslint-disable-next-line testing-library/no-node-access
279
+ const shape = item.querySelector( '.visx-legend-shape' );
280
+ expect( shape ).toHaveStyle( { margin: '4px 8px' } );
275
281
  } );
276
282
  } );
277
283
 
@@ -299,7 +305,7 @@ describe( 'LeaderboardChart', () => {
299
305
  data={ mockData }
300
306
  withComparison={ true }
301
307
  showLegend={ true }
302
- legendInteractive={ true }
308
+ legend={ { interactive: true } }
303
309
  />
304
310
  );
305
311
 
@@ -313,7 +319,7 @@ describe( 'LeaderboardChart', () => {
313
319
  data={ mockData }
314
320
  withComparison={ true }
315
321
  showLegend={ true }
316
- legendInteractive={ false }
322
+ legend={ { interactive: false } }
317
323
  />
318
324
  );
319
325
 
@@ -328,7 +334,7 @@ describe( 'LeaderboardChart', () => {
328
334
  data={ mockData }
329
335
  withComparison={ true }
330
336
  showLegend={ true }
331
- legendInteractive={ true }
337
+ legend={ { interactive: true } }
332
338
  />
333
339
  );
334
340
 
@@ -339,4 +345,26 @@ describe( 'LeaderboardChart', () => {
339
345
  expect( screen.getByText( '-8%' ) ).toBeInTheDocument();
340
346
  } );
341
347
  } );
348
+
349
+ describe( 'Responsive wrapper', () => {
350
+ it( 'fills parent container (height:100%) by default', () => {
351
+ render( <LeaderboardChart data={ mockData } /> );
352
+ const wrapper = screen.getByTestId( 'responsive-wrapper' );
353
+ expect( wrapper ).toHaveStyle( { height: '100%' } );
354
+ } );
355
+
356
+ it( 'applies explicit width and height to chart container', () => {
357
+ const { useParentSize } = jest.requireMock( '@visx/responsive' );
358
+ useParentSize.mockReturnValue( {
359
+ parentRef: { current: null },
360
+ width: 0,
361
+ height: 0,
362
+ } );
363
+
364
+ render( <LeaderboardChart data={ mockData } width={ 500 } height={ 240 } /> );
365
+ const chartContainer = screen.getByTestId( 'leaderboard-chart-container' );
366
+
367
+ expect( chartContainer ).toHaveStyle( { width: '500px', height: '240px' } );
368
+ } );
369
+ } );
342
370
  } );
@@ -80,7 +80,6 @@ describe( 'useLeaderboardLegendItems', () => {
80
80
  expect( result.current ).toHaveLength( 1 );
81
81
  expect( result.current[ 0 ] ).toEqual( {
82
82
  label: 'Current period',
83
- value: '',
84
83
  color: expect.any( String ),
85
84
  } );
86
85
  } );
@@ -102,14 +101,12 @@ describe( 'useLeaderboardLegendItems', () => {
102
101
  // Current period item
103
102
  expect( result.current[ 0 ] ).toEqual( {
104
103
  label: 'Current period',
105
- value: '',
106
104
  color: expect.any( String ),
107
105
  } );
108
106
 
109
107
  // Previous period item
110
108
  expect( result.current[ 1 ] ).toEqual( {
111
109
  label: 'Previous period',
112
- value: '',
113
110
  color: expect.any( String ),
114
111
  } );
115
112
  } );
@@ -585,7 +582,7 @@ describe( 'useLeaderboardLegendItems', () => {
585
582
  expect( result.current[ 1 ].label ).toBe( 'Previous period' );
586
583
  } );
587
584
 
588
- it( 'should have empty value strings for all items', () => {
585
+ it( 'should not include value property for legend items', () => {
589
586
  const wrapper = createWrapper();
590
587
  const { result } = renderHook(
591
588
  () =>
@@ -598,7 +595,7 @@ describe( 'useLeaderboardLegendItems', () => {
598
595
  );
599
596
 
600
597
  result.current.forEach( item => {
601
- expect( item.value ).toBe( '' );
598
+ expect( item ).not.toHaveProperty( 'value' );
602
599
  } );
603
600
  } );
604
601
 
@@ -7,15 +7,12 @@ export interface LeaderboardChartProps
7
7
  | 'className'
8
8
  | 'data'
9
9
  | 'showLegend'
10
- | 'legendOrientation'
11
- | 'legendPosition'
12
- | 'legendAlignment'
13
- | 'legendShape'
10
+ | 'legend'
14
11
  | 'chartId'
15
12
  | 'width'
16
13
  | 'height'
17
14
  | 'size'
18
- | 'legendInteractive'
15
+ | 'gap'
19
16
  | 'animation'
20
17
  > {
21
18
  /**
@@ -60,16 +57,6 @@ export interface LeaderboardChartProps
60
57
  '--a8c--charts--leaderboard--bar--border-radius'?: string;
61
58
  };
62
59
 
63
- /**
64
- * Width of legend shapes in pixels
65
- */
66
- legendShapeWidth?: number;
67
-
68
- /**
69
- * Height of legend shapes in pixels
70
- */
71
- legendShapeHeight?: number;
72
-
73
60
  /**
74
61
  * Custom labels for legend items
75
62
  */
@@ -15,7 +15,6 @@ import {
15
15
  useChartDataTransform,
16
16
  useChartMargin,
17
17
  useElementSize,
18
- useHasLegendChild,
19
18
  usePrefersReducedMotion,
20
19
  } from '../../hooks';
21
20
  import {
@@ -27,6 +26,7 @@ import {
27
26
  useGlobalChartsTheme,
28
27
  } from '../../providers';
29
28
  import { attachSubComponents } from '../../utils';
29
+ import { useChartChildren, renderLegendSlot } from '../private/chart-composition';
30
30
  import { DefaultGlyph } from '../private/default-glyph';
31
31
  import { SingleChartContext, type SingleChartRef } from '../private/single-chart-context';
32
32
  import { withResponsive } from '../private/with-responsive';
@@ -256,15 +256,9 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
256
256
  withTooltips = true,
257
257
  withTooltipCrosshairs,
258
258
  showLegend = false,
259
- legendOrientation = 'horizontal',
260
- legendAlignment = 'center',
261
- legendPosition = 'bottom',
262
- legendMaxWidth,
263
- legendTextOverflow = 'wrap',
264
- legendItemClassName,
259
+ legend = {},
265
260
  renderGlyph = defaultRenderGlyph,
266
261
  glyphStyle = {},
267
- legendShape = 'line',
268
262
  withLegendGlyph = false,
269
263
  withGradientFill = false,
270
264
  smoothing = true,
@@ -272,7 +266,6 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
272
266
  renderTooltip = renderDefaultTooltip,
273
267
  withStartGlyphs = false,
274
268
  withEndGlyphs = false,
275
- legendInteractive = false,
276
269
  animation,
277
270
  options = {},
278
271
  onPointerDown = undefined,
@@ -285,6 +278,10 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
285
278
  },
286
279
  ref
287
280
  ) => {
281
+ const legendInteractive = legend.interactive ?? false;
282
+ const legendShape = legend.shape ?? 'line';
283
+ const legendPosition = legend.position ?? 'bottom';
284
+
288
285
  const providerTheme = useGlobalChartsTheme();
289
286
  const theme = useXYChartTheme( data );
290
287
  const chartId = useChartId( providedChartId );
@@ -294,8 +291,9 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
294
291
  const [ isNavigating, setIsNavigating ] = useState( false );
295
292
  const internalChartRef = useRef< SingleChartRef >( null );
296
293
 
297
- // Check if children contain a Legend component (composition pattern)
298
- const hasLegendChild = useHasLegendChild( children );
294
+ // Process children for composition API (Legend, etc.)
295
+ const { legendChildren, nonLegendChildren } = useChartChildren( children, 'LineChart' );
296
+ const hasLegendChild = legendChildren.length > 0;
299
297
 
300
298
  // Use the measured SVG wrapper height, falling back to the passed height if provided.
301
299
  // When there's a legend (via prop or composition), we must wait for measurement because
@@ -454,12 +452,13 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
454
452
 
455
453
  const legendElement = showLegend && (
456
454
  <Legend
457
- orientation={ legendOrientation }
458
- alignment={ legendAlignment }
455
+ orientation={ legend.orientation ?? 'horizontal' }
456
+ alignment={ legend.alignment ?? 'center' }
459
457
  position={ legendPosition }
460
- maxWidth={ legendMaxWidth }
461
- textOverflow={ legendTextOverflow }
462
- legendItemClassName={ legendItemClassName }
458
+ labelStyles={ legend.labelStyles }
459
+ itemClassName={ legend.itemClassName }
460
+ itemStyles={ legend.itemStyles }
461
+ shapeStyles={ legend.shapeStyles }
463
462
  className={ styles[ 'line-chart__legend' ] }
464
463
  shape={ legendShape }
465
464
  chartId={ chartId }
@@ -493,6 +492,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
493
492
  } }
494
493
  >
495
494
  { legendPosition === 'top' && legendElement }
495
+ { renderLegendSlot( legendChildren, 'top' ) }
496
496
 
497
497
  <div
498
498
  className={ styles[ 'line-chart__svg-wrapper' ] }
@@ -655,8 +655,9 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
655
655
  </div>
656
656
 
657
657
  { legendPosition === 'bottom' && legendElement }
658
+ { renderLegendSlot( legendChildren, 'bottom' ) }
658
659
 
659
- { children }
660
+ { nonLegendChildren }
660
661
  </Stack>
661
662
  </SingleChartContext.Provider>
662
663
  );