@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
@@ -53,13 +53,15 @@ describe( 'LineChart', () => {
53
53
  ],
54
54
  };
55
55
 
56
- const renderWithTheme = ( props = {}, themeName = 'default' ) => {
56
+ const renderWithTheme = ( props = {}, themeName = 'default', children = undefined ) => {
57
57
  const theme = THEME_MAP[ themeName ];
58
58
 
59
59
  return render(
60
60
  <GlobalChartsProvider theme={ theme }>
61
61
  { /* @ts-expect-error TODO Fix the missing props */ }
62
- <LineChart { ...defaultProps } { ...props } />
62
+ <LineChart { ...defaultProps } { ...props }>
63
+ { children }
64
+ </LineChart>
63
65
  </GlobalChartsProvider>
64
66
  );
65
67
  };
@@ -130,36 +132,56 @@ describe( 'LineChart', () => {
130
132
  } );
131
133
 
132
134
  describe( 'Legend', () => {
135
+ const multiSeriesData = [
136
+ {
137
+ label: 'Series A',
138
+ data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
139
+ },
140
+ {
141
+ label: 'Series B',
142
+ data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
143
+ },
144
+ ];
145
+
133
146
  test( 'shows legend when showLegend is true', () => {
134
- renderWithTheme( {
135
- showLegend: true,
136
- data: [
137
- {
138
- label: 'Series A',
139
- data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
140
- },
141
- {
142
- label: 'Series B',
143
- data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
144
- },
145
- ],
146
- } );
147
+ renderWithTheme( { showLegend: true, data: multiSeriesData } );
147
148
  expect( screen.getByText( 'Series A' ) ).toBeInTheDocument();
148
149
  expect( screen.getByText( 'Series B' ) ).toBeInTheDocument();
149
150
  } );
150
151
 
151
152
  test( 'hides legend when showLegend is false', () => {
152
- renderWithTheme( {
153
- showLegend: false,
154
- data: [
155
- {
156
- label: 'Series A',
157
- data: [ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' } ],
158
- },
159
- ],
160
- } );
153
+ renderWithTheme( { showLegend: false, data: multiSeriesData } );
161
154
  expect( screen.queryByText( 'Series A' ) ).not.toBeInTheDocument();
162
155
  } );
156
+
157
+ test( 'renders composition legend as child component', () => {
158
+ renderWithTheme( { data: multiSeriesData }, 'default', <LineChart.Legend /> );
159
+
160
+ expect( screen.getAllByTestId( 'legend-item' ) ).toHaveLength( 2 );
161
+ expect( screen.getByText( 'Series A' ) ).toBeInTheDocument();
162
+ expect( screen.getByText( 'Series B' ) ).toBeInTheDocument();
163
+ } );
164
+
165
+ test( 'renders composition legend regardless of showLegend value', () => {
166
+ renderWithTheme(
167
+ { data: multiSeriesData, showLegend: false },
168
+ 'default',
169
+ <LineChart.Legend />
170
+ );
171
+
172
+ expect( screen.getAllByTestId( 'legend-item' ) ).toHaveLength( 2 );
173
+ } );
174
+
175
+ test( 'renders composition legend in top position', () => {
176
+ renderWithTheme( { data: multiSeriesData }, 'default', <LineChart.Legend position="top" /> );
177
+
178
+ // Legend should appear before the chart content in DOM order
179
+ expect( screen.getAllByTestId( 'legend-item' ) ).toHaveLength( 2 );
180
+ const html = document.body.innerHTML;
181
+ expect( html.indexOf( 'data-testid="legend-horizontal"' ) ).toBeLessThan(
182
+ html.indexOf( 'role="grid"' )
183
+ );
184
+ } );
163
185
  } );
164
186
 
165
187
  describe( 'Gradient Fill', () => {
@@ -1178,7 +1200,7 @@ describe( 'LineChart', () => {
1178
1200
  { ...defaultProps }
1179
1201
  withGradientFill={ false }
1180
1202
  showLegend={ true }
1181
- legendInteractive={ true }
1203
+ legend={ { interactive: true } }
1182
1204
  chartId="test-interactive-chart"
1183
1205
  />
1184
1206
  </GlobalChartsProvider>
@@ -1200,7 +1222,7 @@ describe( 'LineChart', () => {
1200
1222
  { ...defaultProps }
1201
1223
  withGradientFill={ false }
1202
1224
  showLegend={ true }
1203
- legendInteractive={ false }
1225
+ legend={ { interactive: false } }
1204
1226
  chartId="test-non-interactive-chart"
1205
1227
  />
1206
1228
  </GlobalChartsProvider>
@@ -1218,7 +1240,7 @@ describe( 'LineChart', () => {
1218
1240
  { ...defaultProps }
1219
1241
  withGradientFill={ false }
1220
1242
  showLegend={ true }
1221
- legendInteractive={ true }
1243
+ legend={ { interactive: true } }
1222
1244
  // No chartId provided
1223
1245
  />
1224
1246
  </GlobalChartsProvider>
@@ -41,7 +41,6 @@ export interface LineChartProps extends BaseChartProps< SeriesData[] > {
41
41
  showVertical?: boolean;
42
42
  showHorizontal?: boolean;
43
43
  };
44
- legendInteractive?: boolean;
45
44
  children?: ReactNode;
46
45
  }
47
46
 
@@ -18,7 +18,12 @@ import {
18
18
  } from '../../providers';
19
19
  import { attachSubComponents } from '../../utils';
20
20
  import { getStringWidth } from '../../visx/text';
21
- import { ChartSVG, ChartHTML, useChartChildren } from '../private/chart-composition';
21
+ import {
22
+ ChartSVG,
23
+ ChartHTML,
24
+ useChartChildren,
25
+ renderLegendSlot,
26
+ } from '../private/chart-composition';
22
27
  import { RadialWipeAnimation } from '../private/radial-wipe-animation/';
23
28
  import { SingleChartContext } from '../private/single-chart-context';
24
29
  import { withResponsive, ResponsiveConfig } from '../private/with-responsive';
@@ -93,13 +98,6 @@ export interface PieChartProps extends BaseChartProps< DataPointPercentage[] > {
93
98
  */
94
99
  legendValueDisplay?: LegendValueDisplay;
95
100
 
96
- /**
97
- * Enable interactive legend items that can toggle segment visibility.
98
- * Requires chartId and GlobalChartsProvider.
99
- * When segments are hidden, percentages are recalculated so visible segments total 100%.
100
- */
101
- legendInteractive?: boolean;
102
-
103
101
  /**
104
102
  * Use the children prop to render additional elements on the chart.
105
103
  */
@@ -169,13 +167,7 @@ const PieChartInternal = ( {
169
167
  withTooltips = false,
170
168
  className,
171
169
  showLegend = false,
172
- legendOrientation = 'horizontal',
173
- legendPosition = 'bottom',
174
- legendAlignment = 'center',
175
- legendMaxWidth,
176
- legendTextOverflow = 'wrap',
177
- legendItemClassName,
178
- legendShape = 'circle',
170
+ legend = {},
179
171
  width: propWidth,
180
172
  height: propHeight,
181
173
  size,
@@ -186,13 +178,15 @@ const PieChartInternal = ( {
186
178
  cornerScale = 0,
187
179
  showLabels = true,
188
180
  legendValueDisplay = 'percentage',
189
- legendInteractive = false,
190
181
  children = null,
191
182
  tooltipOffsetX = 0,
192
183
  tooltipOffsetY = -15,
193
184
  renderTooltip = renderDefaultPieTooltip,
194
185
  gap = 'md',
195
186
  }: PieChartProps ) => {
187
+ const legendInteractive = legend.interactive ?? false;
188
+ const legendPosition = legend.position ?? 'bottom';
189
+
196
190
  const providerTheme = useGlobalChartsTheme();
197
191
  const chartId = useChartId( providedChartId );
198
192
  const [ svgWrapperRef, svgWrapperWidth, svgWrapperHeight ] = useElementSize< HTMLDivElement >();
@@ -236,7 +230,10 @@ const PieChartInternal = ( {
236
230
  const { isValid, message } = validateData( data );
237
231
 
238
232
  // Process children to extract compound components
239
- const { svgChildren, htmlChildren, otherChildren } = useChartChildren( children, 'PieChart' );
233
+ const { svgChildren, htmlChildren, legendChildren, otherChildren } = useChartChildren(
234
+ children,
235
+ 'PieChart'
236
+ );
240
237
 
241
238
  // Memoize metadata to prevent unnecessary re-registration
242
239
  const chartMetadata = useMemo(
@@ -314,13 +311,14 @@ const PieChartInternal = ( {
314
311
 
315
312
  const legendElement = showLegend && (
316
313
  <Legend
317
- orientation={ legendOrientation }
314
+ orientation={ legend.orientation ?? 'horizontal' }
318
315
  position={ legendPosition }
319
- alignment={ legendAlignment }
320
- maxWidth={ legendMaxWidth }
321
- textOverflow={ legendTextOverflow }
322
- legendItemClassName={ legendItemClassName }
323
- shape={ legendShape }
316
+ alignment={ legend.alignment ?? 'center' }
317
+ labelStyles={ legend.labelStyles }
318
+ itemClassName={ legend.itemClassName }
319
+ itemStyles={ legend.itemStyles }
320
+ shapeStyles={ legend.shapeStyles }
321
+ shape={ legend.shape ?? 'circle' }
324
322
  chartId={ chartId }
325
323
  interactive={ legendInteractive }
326
324
  />
@@ -351,6 +349,7 @@ const PieChartInternal = ( {
351
349
  } }
352
350
  >
353
351
  { legendPosition === 'top' && legendElement }
352
+ { renderLegendSlot( legendChildren, 'top' ) }
354
353
 
355
354
  <div className={ styles[ 'pie-chart__svg-wrapper' ] } ref={ svgWrapperRef }>
356
355
  <svg
@@ -483,6 +482,7 @@ const PieChartInternal = ( {
483
482
  </div>
484
483
 
485
484
  { legendPosition === 'bottom' && legendElement }
485
+ { renderLegendSlot( legendChildren, 'bottom' ) }
486
486
 
487
487
  { withTooltips && tooltipOpen && tooltipData && (
488
488
  <TooltipInPortal top={ tooltipTop || 0 } left={ tooltipLeft || 0 }>
@@ -1,6 +1,7 @@
1
1
  import { render, screen } from '@testing-library/react';
2
2
  import { Group } from '@visx/group';
3
3
  import '@testing-library/jest-dom';
4
+ import { GlobalChartsProvider } from '../../../providers';
4
5
  import { PieChartUnresponsive as PieChart } from '../index';
5
6
 
6
7
  describe( 'PieChart Composition API', () => {
@@ -10,6 +11,16 @@ describe( 'PieChart Composition API', () => {
10
11
  { label: 'C', value: 30, percentage: 30 },
11
12
  ];
12
13
 
14
+ const renderWithChildren = ( props = {}, children = undefined ) => {
15
+ return render(
16
+ <GlobalChartsProvider>
17
+ <PieChart data={ mockData } size={ 400 } { ...props }>
18
+ { children }
19
+ </PieChart>
20
+ </GlobalChartsProvider>
21
+ );
22
+ };
23
+
13
24
  describe( 'Compound Components', () => {
14
25
  it( 'renders PieChart.SVG children inside the SVG element', () => {
15
26
  render(
@@ -148,4 +159,34 @@ describe( 'PieChart Composition API', () => {
148
159
  expect( svg!.contains( newHtml ) ).toBe( false );
149
160
  } );
150
161
  } );
162
+
163
+ describe( 'Composition Legend', () => {
164
+ test( 'renders composition legend as child component', () => {
165
+ renderWithChildren( {}, <PieChart.Legend /> );
166
+
167
+ const legendItems = screen.getAllByTestId( 'legend-item' );
168
+ expect( legendItems ).toHaveLength( 3 );
169
+ expect( legendItems[ 0 ] ).toHaveTextContent( 'A' );
170
+ expect( legendItems[ 1 ] ).toHaveTextContent( 'B' );
171
+ expect( legendItems[ 2 ] ).toHaveTextContent( 'C' );
172
+ } );
173
+
174
+ test( 'renders composition legend regardless of showLegend value', () => {
175
+ renderWithChildren( { showLegend: false }, <PieChart.Legend /> );
176
+
177
+ expect( screen.getAllByTestId( 'legend-item' ) ).toHaveLength( 3 );
178
+ } );
179
+
180
+ test( 'renders composition legend in top position', () => {
181
+ renderWithChildren( {}, <PieChart.Legend position="top" /> );
182
+
183
+ expect( screen.getAllByTestId( 'legend-item' ) ).toHaveLength( 3 );
184
+
185
+ // Legend should appear before the chart SVG in DOM order
186
+ const html = document.body.innerHTML;
187
+ expect( html.indexOf( 'data-testid="legend-horizontal"' ) ).toBeLessThan(
188
+ html.indexOf( 'data-testid="pie-segment"' )
189
+ );
190
+ } );
191
+ } );
151
192
  } );
@@ -60,7 +60,7 @@ describe( 'PieChart', () => {
60
60
  test( 'renders legend when showLegend is true', () => {
61
61
  renderWithTheme( {
62
62
  showLegend: true,
63
- legendPosition: 'top',
63
+ legend: { position: 'top' },
64
64
  } );
65
65
 
66
66
  // Check that legend container is rendered using accessible queries
@@ -72,7 +72,7 @@ describe( 'PieChart', () => {
72
72
  test( 'renders correct number of legend items', () => {
73
73
  renderWithTheme( {
74
74
  showLegend: true,
75
- legendPosition: 'top',
75
+ legend: { position: 'top' },
76
76
  } );
77
77
 
78
78
  // Use getAllByTestId to find legend items
@@ -83,7 +83,7 @@ describe( 'PieChart', () => {
83
83
  test( 'chart renders with legend at top position', () => {
84
84
  renderWithTheme( {
85
85
  showLegend: true,
86
- legendPosition: 'top',
86
+ legend: { position: 'top' },
87
87
  } );
88
88
 
89
89
  // Verify the chart renders without errors when legend is at top
@@ -98,7 +98,7 @@ describe( 'PieChart', () => {
98
98
  test( 'chart renders with legend at bottom position', () => {
99
99
  renderWithTheme( {
100
100
  showLegend: true,
101
- legendPosition: 'bottom',
101
+ legend: { position: 'bottom' },
102
102
  } );
103
103
 
104
104
  // Verify the chart renders without errors when legend is at bottom
@@ -379,7 +379,7 @@ describe( 'PieChart', () => {
379
379
  renderWithTheme( {
380
380
  data: testData,
381
381
  showLegend: true,
382
- legendInteractive: true,
382
+ legend: { interactive: true },
383
383
  chartId: 'test-interactive-pie-chart',
384
384
  } );
385
385
 
@@ -411,7 +411,7 @@ describe( 'PieChart', () => {
411
411
  renderWithTheme( {
412
412
  data: testData,
413
413
  showLegend: true,
414
- legendInteractive: true,
414
+ legend: { interactive: true },
415
415
  chartId: 'test-all-hidden-pie-chart',
416
416
  } );
417
417
 
@@ -450,7 +450,7 @@ describe( 'PieChart', () => {
450
450
  renderWithTheme( {
451
451
  data: testData,
452
452
  showLegend: true,
453
- legendInteractive: false,
453
+ legend: { interactive: false },
454
454
  chartId: 'test-non-interactive-pie-chart',
455
455
  } );
456
456
 
@@ -474,7 +474,7 @@ describe( 'PieChart', () => {
474
474
  renderWithTheme( {
475
475
  data: testData,
476
476
  showLegend: true,
477
- legendInteractive: true,
477
+ legend: { interactive: true },
478
478
  chartId: 'test-color-consistency-pie-chart',
479
479
  } );
480
480
 
@@ -506,7 +506,7 @@ describe( 'PieChart', () => {
506
506
  renderWithTheme( {
507
507
  data: testData,
508
508
  showLegend: true,
509
- legendInteractive: true,
509
+ legend: { interactive: true },
510
510
  legendValueDisplay: 'percentage',
511
511
  chartId: 'test-percentage-recalc-pie-chart',
512
512
  } );
@@ -17,7 +17,12 @@ import {
17
17
  GlobalChartsContext,
18
18
  } from '../../providers';
19
19
  import { attachSubComponents } from '../../utils';
20
- import { ChartSVG, ChartHTML, useChartChildren } from '../private/chart-composition';
20
+ import {
21
+ ChartSVG,
22
+ ChartHTML,
23
+ useChartChildren,
24
+ renderLegendSlot,
25
+ } from '../private/chart-composition';
21
26
  import { RadialWipeAnimation } from '../private/radial-wipe-animation';
22
27
  import { SingleChartContext } from '../private/single-chart-context';
23
28
  import { withResponsive } from '../private/with-responsive';
@@ -98,13 +103,6 @@ export interface PieSemiCircleChartProps extends BaseChartProps< DataPointPercen
98
103
  */
99
104
  legendValueDisplay?: LegendValueDisplay;
100
105
 
101
- /**
102
- * Enable interactive legend items that can toggle segment visibility.
103
- * Requires chartId and GlobalChartsProvider.
104
- * When segments are hidden, percentages are recalculated so visible segments total 100%.
105
- */
106
- legendInteractive?: boolean;
107
-
108
106
  /**
109
107
  * Horizontal offset for tooltip positioning in pixels (default: 0)
110
108
  */
@@ -167,15 +165,8 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
167
165
  clockwise = true,
168
166
  withTooltips = false,
169
167
  showLegend = false,
170
- legendOrientation = 'horizontal',
171
- legendPosition = 'bottom',
172
- legendAlignment = 'center',
173
- legendMaxWidth,
174
- legendTextOverflow = 'wrap',
175
- legendItemClassName,
176
- legendShape = 'circle',
168
+ legend = {},
177
169
  legendValueDisplay = 'percentage',
178
- legendInteractive = false,
179
170
  label,
180
171
  animation,
181
172
  note,
@@ -186,6 +177,9 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
186
177
  renderTooltip = renderDefaultPieSemiCircleTooltip,
187
178
  gap = 'md',
188
179
  } ) => {
180
+ const legendInteractive = legend.interactive ?? false;
181
+ const legendPosition = legend.position ?? 'bottom';
182
+
189
183
  const chartId = useChartId( providedChartId );
190
184
  // Measure the SVG wrapper to calculate constrained dimensions
191
185
  const [ svgWrapperRef, svgWrapperWidth, svgWrapperHeight ] = useElementSize< HTMLDivElement >();
@@ -277,7 +271,7 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
277
271
  const legendItems = useChartLegendItems( legendData, legendOptions );
278
272
 
279
273
  // Process children to extract compound components
280
- const { svgChildren, htmlChildren, otherChildren } = useChartChildren(
274
+ const { svgChildren, htmlChildren, legendChildren, otherChildren } = useChartChildren(
281
275
  children,
282
276
  'PieSemiCircleChart'
283
277
  );
@@ -349,13 +343,14 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
349
343
 
350
344
  const legendElement = showLegend && (
351
345
  <Legend
352
- orientation={ legendOrientation }
346
+ orientation={ legend.orientation ?? 'horizontal' }
353
347
  position={ legendPosition }
354
- alignment={ legendAlignment }
355
- maxWidth={ legendMaxWidth }
356
- textOverflow={ legendTextOverflow }
357
- legendItemClassName={ legendItemClassName }
358
- shape={ legendShape }
348
+ alignment={ legend.alignment ?? 'center' }
349
+ labelStyles={ legend.labelStyles }
350
+ itemClassName={ legend.itemClassName }
351
+ itemStyles={ legend.itemStyles }
352
+ shapeStyles={ legend.shapeStyles }
353
+ shape={ legend.shape ?? 'circle' }
359
354
  chartId={ chartId }
360
355
  interactive={ legendInteractive }
361
356
  />
@@ -388,6 +383,7 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
388
383
  data-testid="pie-chart-container"
389
384
  >
390
385
  { legendPosition === 'top' && legendElement }
386
+ { renderLegendSlot( legendChildren, 'top' ) }
391
387
 
392
388
  <div ref={ svgWrapperRef } className={ styles[ 'pie-semi-circle-chart__svg-wrapper' ] }>
393
389
  <svg
@@ -484,7 +480,8 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
484
480
  </svg>
485
481
  </div>
486
482
 
487
- { legendPosition !== 'top' && legendElement }
483
+ { legendPosition === 'bottom' && legendElement }
484
+ { renderLegendSlot( legendChildren, 'bottom' ) }
488
485
 
489
486
  { withTooltips && tooltipOpen && tooltipData && (
490
487
  <TooltipInPortal top={ tooltipTop || 0 } left={ tooltipLeft || 0 }>
@@ -29,10 +29,10 @@ const mockData = [
29
29
  ];
30
30
 
31
31
  // Helper function to render component with providers
32
- const renderPieChart = props =>
32
+ const renderPieChart = ( props, children = undefined ) =>
33
33
  render(
34
34
  <GlobalChartsProvider>
35
- <PieSemiCircleChart { ...props } />
35
+ <PieSemiCircleChart { ...props }>{ children }</PieSemiCircleChart>
36
36
  </GlobalChartsProvider>
37
37
  );
38
38
 
@@ -256,6 +256,34 @@ describe( 'PieSemiCircleChart', () => {
256
256
  } );
257
257
  } );
258
258
 
259
+ describe( 'Composition Legend', () => {
260
+ test( 'renders composition legend as child component', () => {
261
+ renderPieChart( { data: mockData }, <PieSemiCircleChart.Legend /> );
262
+
263
+ expect( screen.getAllByTestId( 'legend-item' ) ).toHaveLength( 2 );
264
+ expect( screen.getByText( 'Category A' ) ).toBeInTheDocument();
265
+ expect( screen.getByText( 'Category B' ) ).toBeInTheDocument();
266
+ } );
267
+
268
+ test( 'renders composition legend regardless of showLegend value', () => {
269
+ renderPieChart( { data: mockData, showLegend: false }, <PieSemiCircleChart.Legend /> );
270
+
271
+ expect( screen.getAllByTestId( 'legend-item' ) ).toHaveLength( 2 );
272
+ } );
273
+
274
+ test( 'renders composition legend in top position', () => {
275
+ renderPieChart( { data: mockData }, <PieSemiCircleChart.Legend position="top" /> );
276
+
277
+ expect( screen.getAllByTestId( 'legend-item' ) ).toHaveLength( 2 );
278
+
279
+ // Legend should appear before the chart SVG in DOM order
280
+ const html = document.body.innerHTML;
281
+ expect( html.indexOf( 'data-testid="legend-horizontal"' ) ).toBeLessThan(
282
+ html.indexOf( 'data-testid="pie-chart-svg"' )
283
+ );
284
+ } );
285
+ } );
286
+
259
287
  describe( 'Interactive Legend', () => {
260
288
  test( 'filters segments when interactive legend is enabled and segment is toggled', async () => {
261
289
  const user = userEvent.setup();
@@ -267,7 +295,7 @@ describe( 'PieSemiCircleChart', () => {
267
295
  renderPieChart( {
268
296
  data: testData,
269
297
  showLegend: true,
270
- legendInteractive: true,
298
+ legend: { interactive: true },
271
299
  chartId: 'test-interactive-semi-circle-chart',
272
300
  } );
273
301
 
@@ -299,7 +327,7 @@ describe( 'PieSemiCircleChart', () => {
299
327
  renderPieChart( {
300
328
  data: testData,
301
329
  showLegend: true,
302
- legendInteractive: true,
330
+ legend: { interactive: true },
303
331
  chartId: 'test-all-hidden-semi-circle-chart',
304
332
  } );
305
333
 
@@ -326,7 +354,7 @@ describe( 'PieSemiCircleChart', () => {
326
354
  renderPieChart( {
327
355
  data: testData,
328
356
  showLegend: true,
329
- legendInteractive: false,
357
+ legend: { interactive: false },
330
358
  chartId: 'test-non-interactive-semi-circle-chart',
331
359
  } );
332
360
 
@@ -1,4 +1,6 @@
1
1
  export { ChartSVG } from './chart-svg';
2
2
  export { ChartHTML } from './chart-html';
3
+ export { renderLegendSlot } from './render-legend-slot';
3
4
  export { useChartChildren } from './use-chart-children';
5
+ export type { LegendChild } from './use-chart-children';
4
6
  export type { BaseChartSubComponents, ChartComponentWithComposition } from './types';
@@ -0,0 +1,22 @@
1
+ import { createElement, Fragment } from 'react';
2
+ import type { LegendChild } from './use-chart-children';
3
+ import type { LegendPosition } from '../../../types';
4
+ import type { ReactNode } from 'react';
5
+
6
+ /**
7
+ * Renders legend children filtered by position slot.
8
+ *
9
+ * @param {LegendChild[]} legendChildren - The legend children to filter and render
10
+ * @param {LegendPosition} position - The position slot to render
11
+ * @return {ReactNode[]} Array of legend elements for the given position
12
+ */
13
+ export function renderLegendSlot(
14
+ legendChildren: LegendChild[],
15
+ position: LegendPosition
16
+ ): ReactNode[] {
17
+ return legendChildren
18
+ .filter( l => l.position === position )
19
+ .map( ( l, i ) =>
20
+ createElement( Fragment, { key: `legend-${ position }-${ i }` }, l.element )
21
+ );
22
+ }
@@ -0,0 +1,60 @@
1
+ import { createElement } from 'react';
2
+ import { renderLegendSlot } from '../render-legend-slot';
3
+ import type { LegendChild } from '../use-chart-children';
4
+
5
+ const makeLegend = ( position: 'top' | 'bottom', label: string ): LegendChild => ( {
6
+ element: createElement( 'div', null, label ),
7
+ position,
8
+ } );
9
+
10
+ describe( 'renderLegendSlot', () => {
11
+ it( 'should return an empty array when given no children', () => {
12
+ expect( renderLegendSlot( [], 'top' ) ).toHaveLength( 0 );
13
+ } );
14
+
15
+ it( 'should return an empty array when no children match the position', () => {
16
+ const children = [ makeLegend( 'bottom', 'Legend 1' ) ];
17
+
18
+ expect( renderLegendSlot( children, 'top' ) ).toHaveLength( 0 );
19
+ } );
20
+
21
+ it( 'should return a single matching child', () => {
22
+ const children = [ makeLegend( 'top', 'Legend 1' ) ];
23
+ const view = renderLegendSlot( children, 'top' );
24
+
25
+ expect( view ).toHaveLength( 1 );
26
+ expect( view[ 0 ] ).toHaveProperty( 'key', 'legend-top-0' );
27
+ } );
28
+
29
+ it( 'should return multiple matching children in order', () => {
30
+ const children = [ makeLegend( 'bottom', 'Legend 1' ), makeLegend( 'bottom', 'Legend 2' ) ];
31
+ const view = renderLegendSlot( children, 'bottom' );
32
+
33
+ expect( view ).toHaveLength( 2 );
34
+ expect( view[ 0 ] ).toHaveProperty( 'key', 'legend-bottom-0' );
35
+ expect( view[ 1 ] ).toHaveProperty( 'key', 'legend-bottom-1' );
36
+ } );
37
+
38
+ it( 'should filter out children with a different position', () => {
39
+ const children = [
40
+ makeLegend( 'top', 'Top Legend' ),
41
+ makeLegend( 'bottom', 'Bottom Legend' ),
42
+ makeLegend( 'top', 'Another Top Legend' ),
43
+ ];
44
+
45
+ expect( renderLegendSlot( children, 'top' ) ).toHaveLength( 2 );
46
+ expect( renderLegendSlot( children, 'bottom' ) ).toHaveLength( 1 );
47
+ } );
48
+
49
+ it( 'should produce distinct keys for top and bottom slots', () => {
50
+ const children = [ makeLegend( 'top', 'Top' ), makeLegend( 'bottom', 'Bottom' ) ];
51
+ const [ topElement, bottomElement ] = [
52
+ renderLegendSlot( children, 'top' )[ 0 ],
53
+ renderLegendSlot( children, 'bottom' )[ 0 ],
54
+ ];
55
+
56
+ expect( topElement ).toHaveProperty( 'key', 'legend-top-0' );
57
+ expect( bottomElement ).toHaveProperty( 'key', 'legend-bottom-0' );
58
+ expect( topElement ).not.toEqual( bottomElement );
59
+ } );
60
+ } );