@automattic/charts 0.57.0 → 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 (210) hide show
  1. package/CHANGELOG.md +16 -2
  2. package/dist/charts/bar-chart/index.cjs +7 -5
  3. package/dist/charts/bar-chart/index.cjs.map +1 -1
  4. package/dist/charts/bar-chart/index.css +12 -24
  5. package/dist/charts/bar-chart/index.css.map +1 -1
  6. package/dist/charts/bar-chart/index.d.cts +3 -4
  7. package/dist/charts/bar-chart/index.d.ts +3 -4
  8. package/dist/charts/bar-chart/index.js +6 -4
  9. package/dist/charts/bar-list-chart/index.cjs +8 -6
  10. package/dist/charts/bar-list-chart/index.cjs.map +1 -1
  11. package/dist/charts/bar-list-chart/index.css +12 -24
  12. package/dist/charts/bar-list-chart/index.css.map +1 -1
  13. package/dist/charts/bar-list-chart/index.d.cts +3 -3
  14. package/dist/charts/bar-list-chart/index.d.ts +3 -3
  15. package/dist/charts/bar-list-chart/index.js +7 -5
  16. package/dist/charts/conversion-funnel-chart/index.cjs +5 -5
  17. package/dist/charts/conversion-funnel-chart/index.css +0 -94
  18. package/dist/charts/conversion-funnel-chart/index.css.map +1 -1
  19. package/dist/charts/conversion-funnel-chart/index.d.cts +1 -1
  20. package/dist/charts/conversion-funnel-chart/index.d.ts +1 -1
  21. package/dist/charts/conversion-funnel-chart/index.js +4 -4
  22. package/dist/charts/geo-chart/index.cjs +4 -4
  23. package/dist/charts/geo-chart/index.css +0 -94
  24. package/dist/charts/geo-chart/index.css.map +1 -1
  25. package/dist/charts/geo-chart/index.d.cts +1 -1
  26. package/dist/charts/geo-chart/index.d.ts +1 -1
  27. package/dist/charts/geo-chart/index.js +3 -3
  28. package/dist/charts/leaderboard-chart/index.cjs +7 -6
  29. package/dist/charts/leaderboard-chart/index.cjs.map +1 -1
  30. package/dist/charts/leaderboard-chart/index.css +12 -24
  31. package/dist/charts/leaderboard-chart/index.css.map +1 -1
  32. package/dist/charts/leaderboard-chart/index.d.cts +3 -3
  33. package/dist/charts/leaderboard-chart/index.d.ts +3 -3
  34. package/dist/charts/leaderboard-chart/index.js +6 -5
  35. package/dist/charts/line-chart/index.cjs +7 -5
  36. package/dist/charts/line-chart/index.cjs.map +1 -1
  37. package/dist/charts/line-chart/index.css +12 -24
  38. package/dist/charts/line-chart/index.css.map +1 -1
  39. package/dist/charts/line-chart/index.d.cts +3 -4
  40. package/dist/charts/line-chart/index.d.ts +3 -4
  41. package/dist/charts/line-chart/index.js +6 -4
  42. package/dist/charts/pie-chart/index.cjs +7 -6
  43. package/dist/charts/pie-chart/index.cjs.map +1 -1
  44. package/dist/charts/pie-chart/index.css +12 -24
  45. package/dist/charts/pie-chart/index.css.map +1 -1
  46. package/dist/charts/pie-chart/index.d.cts +7 -13
  47. package/dist/charts/pie-chart/index.d.ts +7 -13
  48. package/dist/charts/pie-chart/index.js +6 -5
  49. package/dist/charts/pie-semi-circle-chart/index.cjs +7 -6
  50. package/dist/charts/pie-semi-circle-chart/index.cjs.map +1 -1
  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 -5
  56. package/dist/charts/sparkline/index.cjs +8 -6
  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 -5
  61. package/dist/{chunk-32DH6JDF.js → chunk-2I67QUIV.js} +52 -420
  62. package/dist/chunk-2I67QUIV.js.map +1 -0
  63. package/dist/{chunk-WLODYNLB.js → chunk-2ICEEQOC.js} +31 -27
  64. package/dist/chunk-2ICEEQOC.js.map +1 -0
  65. package/dist/{chunk-IU4DYUAV.js → chunk-4B7BL2DD.js} +3 -3
  66. package/dist/{chunk-BCX5THDQ.js → chunk-4OXMTKAL.js} +24 -26
  67. package/dist/chunk-4OXMTKAL.js.map +1 -0
  68. package/dist/{chunk-4OPFE4RM.js → chunk-B6NLZFRW.js} +30 -27
  69. package/dist/chunk-B6NLZFRW.js.map +1 -0
  70. package/dist/{chunk-D2UH4CFE.cjs → chunk-BBAUQOW6.cjs} +9 -9
  71. package/dist/{chunk-D2UH4CFE.cjs.map → chunk-BBAUQOW6.cjs.map} +1 -1
  72. package/dist/{chunk-XKRJL2QT.cjs → chunk-CMMHCTBX.cjs} +45 -47
  73. package/dist/chunk-CMMHCTBX.cjs.map +1 -0
  74. package/dist/{chunk-YE2T52VZ.cjs → chunk-CPPXJATQ.cjs} +51 -47
  75. package/dist/chunk-CPPXJATQ.cjs.map +1 -0
  76. package/dist/{chunk-H2V4JMSA.js → chunk-DKU775VC.js} +3 -3
  77. package/dist/{chunk-ZH4F5RMG.cjs → chunk-GRA7Y2ZG.cjs} +46 -48
  78. package/dist/chunk-GRA7Y2ZG.cjs.map +1 -0
  79. package/dist/{chunk-DAU3HNEG.js → chunk-JJIMABHT.js} +9 -2
  80. package/dist/chunk-JJIMABHT.js.map +1 -0
  81. package/dist/{chunk-CZGYJKG6.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-V36ERY7Y.js → chunk-LTFH7SEG.js} +24 -26
  85. package/dist/chunk-LTFH7SEG.js.map +1 -0
  86. package/dist/{chunk-PXLEMUGJ.js → chunk-MUNOKLLE.js} +3 -3
  87. package/dist/{chunk-VTS3PNMS.cjs → chunk-MXGLYWVP.cjs} +9 -2
  88. package/dist/chunk-MXGLYWVP.cjs.map +1 -0
  89. package/dist/{chunk-Z45KX47P.cjs → chunk-OYC34VTO.cjs} +154 -94
  90. package/dist/chunk-OYC34VTO.cjs.map +1 -0
  91. package/dist/{chunk-77OKCVQN.cjs → chunk-PQL5I3F6.cjs} +17 -17
  92. package/dist/{chunk-77OKCVQN.cjs.map → chunk-PQL5I3F6.cjs.map} +1 -1
  93. package/dist/{chunk-I35UYJJR.cjs → chunk-REZTQ4PH.cjs} +41 -21
  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-2NCY7R4G.js → chunk-UTYVIOWZ.js} +111 -51
  98. package/dist/chunk-UTYVIOWZ.js.map +1 -0
  99. package/dist/{chunk-TO3OQBXG.cjs → chunk-W2LDIX26.cjs} +5 -5
  100. package/dist/{chunk-TO3OQBXG.cjs.map → chunk-W2LDIX26.cjs.map} +1 -1
  101. package/dist/{chunk-7FQX4ALL.cjs → chunk-WSG64BVN.cjs} +6 -6
  102. package/dist/{chunk-7FQX4ALL.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-RHHVEJHJ.cjs → chunk-WYK7EL5R.cjs} +68 -436
  106. package/dist/chunk-WYK7EL5R.cjs.map +1 -0
  107. package/dist/{chunk-VJM5XCB4.cjs → chunk-XC4KHJYX.cjs} +49 -46
  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-Z26M4V2M.js → chunk-YAFQVVDI.js} +41 -21
  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 +3 -11
  127. package/dist/hooks/index.d.ts +3 -11
  128. package/dist/hooks/index.js +2 -4
  129. package/dist/index.cjs +18 -16
  130. package/dist/index.cjs.map +1 -1
  131. package/dist/index.css +12 -24
  132. package/dist/index.css.map +1 -1
  133. package/dist/index.d.cts +7 -7
  134. package/dist/index.d.ts +7 -7
  135. package/dist/index.js +17 -15
  136. package/dist/{leaderboard-chart-DR7CGb0L.d.cts → leaderboard-chart-BSbg0ufV.d.cts} +3 -7
  137. package/dist/{leaderboard-chart-BKYYXcg2.d.ts → leaderboard-chart-odEYxxEC.d.ts} +3 -7
  138. package/dist/{legend-C2grwnWk.d.cts → legend-DFkosEvC.d.cts} +1 -1
  139. package/dist/{legend-Cj0xM5dU.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-CyjKm-P_.d.cts → themes-D0qc5JaW.d.cts} +2 -2
  147. package/dist/{themes-BmVGrYnF.d.ts → themes-itO4Ht5g.d.ts} +2 -2
  148. package/dist/{types-KtOPPzPX.d.cts → types-B5f6XQ7Q.d.cts} +1 -1
  149. package/dist/{types-CuUEszrM.d.ts → types-BsHooDbM.d.ts} +1 -1
  150. package/dist/{types-I67mddpr.d.cts → types-BuSrRM4p.d.ts} +3 -32
  151. package/dist/{types-DZordNiO.d.cts → types-ChOUI9-N.d.cts} +80 -40
  152. package/dist/{types-DZordNiO.d.ts → types-ChOUI9-N.d.ts} +80 -40
  153. package/dist/{types-I67mddpr.d.ts → types-Dfw9VOKI.d.cts} +3 -32
  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 +6 -6
  159. package/src/charts/bar-chart/bar-chart.tsx +17 -18
  160. package/src/charts/bar-chart/test/bar-chart.test.tsx +48 -31
  161. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +38 -41
  162. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +4 -5
  163. package/src/charts/leaderboard-chart/types.ts +1 -11
  164. package/src/charts/line-chart/line-chart.tsx +18 -16
  165. package/src/charts/line-chart/test/line-chart.test.tsx +49 -27
  166. package/src/charts/line-chart/types.ts +0 -1
  167. package/src/charts/pie-chart/pie-chart.tsx +23 -22
  168. package/src/charts/pie-chart/test/composition-api.test.tsx +41 -0
  169. package/src/charts/pie-chart/test/pie-chart.test.tsx +9 -9
  170. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +21 -23
  171. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +33 -5
  172. package/src/charts/private/chart-composition/index.ts +2 -0
  173. package/src/charts/private/chart-composition/render-legend-slot.ts +22 -0
  174. package/src/charts/private/chart-composition/test/render-legend-slot.test.tsx +60 -0
  175. package/src/charts/private/chart-composition/test/use-chart-children.test.tsx +91 -0
  176. package/src/charts/private/chart-composition/use-chart-children.ts +34 -2
  177. package/src/components/legend/index.ts +1 -8
  178. package/src/components/legend/private/base-legend.module.scss +19 -37
  179. package/src/components/legend/private/base-legend.tsx +0 -2
  180. package/src/components/legend/types.ts +7 -34
  181. package/src/hooks/index.ts +0 -1
  182. package/src/index.ts +1 -7
  183. package/src/types.ts +83 -38
  184. package/src/utils/date-parsing.ts +10 -1
  185. package/src/utils/test/date-parsing.test.ts +12 -0
  186. package/src/utils/test/resolve-css-var.test.ts +2 -2
  187. package/tsup.config.ts +1 -1
  188. package/dist/chunk-2NCY7R4G.js.map +0 -1
  189. package/dist/chunk-32DH6JDF.js.map +0 -1
  190. package/dist/chunk-4OPFE4RM.js.map +0 -1
  191. package/dist/chunk-6CCZL2JJ.js.map +0 -1
  192. package/dist/chunk-BCX5THDQ.js.map +0 -1
  193. package/dist/chunk-DAU3HNEG.js.map +0 -1
  194. package/dist/chunk-I35UYJJR.cjs.map +0 -1
  195. package/dist/chunk-RCY6XLGU.cjs.map +0 -1
  196. package/dist/chunk-RHHVEJHJ.cjs.map +0 -1
  197. package/dist/chunk-V36ERY7Y.js.map +0 -1
  198. package/dist/chunk-VJM5XCB4.cjs.map +0 -1
  199. package/dist/chunk-VTS3PNMS.cjs.map +0 -1
  200. package/dist/chunk-WLODYNLB.js.map +0 -1
  201. package/dist/chunk-XKRJL2QT.cjs.map +0 -1
  202. package/dist/chunk-YE2T52VZ.cjs.map +0 -1
  203. package/dist/chunk-Z26M4V2M.js.map +0 -1
  204. package/dist/chunk-Z45KX47P.cjs.map +0 -1
  205. package/dist/chunk-ZH4F5RMG.cjs.map +0 -1
  206. package/src/hooks/use-has-legend-child.ts +0 -22
  207. /package/dist/{chunk-IU4DYUAV.js.map → chunk-4B7BL2DD.js.map} +0 -0
  208. /package/dist/{chunk-H2V4JMSA.js.map → chunk-DKU775VC.js.map} +0 -0
  209. /package/dist/{chunk-CZGYJKG6.js.map → chunk-KJHWXOCZ.js.map} +0 -0
  210. /package/dist/{chunk-PXLEMUGJ.js.map → chunk-MUNOKLLE.js.map} +0 -0
@@ -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,12 +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
- labelStyles={ { maxWidth: legendMaxWidth, textOverflow: legendTextOverflow } }
356
- itemClassName={ legendItemClassName }
357
- 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' }
358
354
  chartId={ chartId }
359
355
  interactive={ legendInteractive }
360
356
  />
@@ -387,6 +383,7 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
387
383
  data-testid="pie-chart-container"
388
384
  >
389
385
  { legendPosition === 'top' && legendElement }
386
+ { renderLegendSlot( legendChildren, 'top' ) }
390
387
 
391
388
  <div ref={ svgWrapperRef } className={ styles[ 'pie-semi-circle-chart__svg-wrapper' ] }>
392
389
  <svg
@@ -483,7 +480,8 @@ const PieSemiCircleChartInternal: FC< PieSemiCircleChartProps > = ( {
483
480
  </svg>
484
481
  </div>
485
482
 
486
- { legendPosition !== 'top' && legendElement }
483
+ { legendPosition === 'bottom' && legendElement }
484
+ { renderLegendSlot( legendChildren, 'bottom' ) }
487
485
 
488
486
  { withTooltips && tooltipOpen && tooltipData && (
489
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
+ } );
@@ -1,5 +1,6 @@
1
1
  import { renderHook } from '@testing-library/react';
2
2
  import { Group } from '@visx/group';
3
+ import { Legend } from '../../../../components/legend';
3
4
  import { ChartSVG, ChartHTML } from '../index';
4
5
  import { useChartChildren } from '../use-chart-children';
5
6
 
@@ -22,6 +23,7 @@ describe( 'useChartChildren', () => {
22
23
 
23
24
  expect( result.current.svgChildren ).toHaveLength( 1 );
24
25
  expect( result.current.htmlChildren ).toHaveLength( 0 );
26
+ expect( result.current.legendChildren ).toHaveLength( 0 );
25
27
  expect( result.current.otherChildren ).toHaveLength( 0 );
26
28
  } );
27
29
 
@@ -58,6 +60,7 @@ describe( 'useChartChildren', () => {
58
60
 
59
61
  expect( result.current.svgChildren ).toHaveLength( 1 );
60
62
  expect( result.current.htmlChildren ).toHaveLength( 1 );
63
+ expect( result.current.legendChildren ).toHaveLength( 0 );
61
64
  expect( result.current.otherChildren ).toHaveLength( 0 );
62
65
  } );
63
66
 
@@ -112,6 +115,7 @@ describe( 'useChartChildren', () => {
112
115
 
113
116
  expect( result.current.svgChildren ).toHaveLength( 0 );
114
117
  expect( result.current.htmlChildren ).toHaveLength( 0 );
118
+ expect( result.current.legendChildren ).toHaveLength( 0 );
115
119
  expect( result.current.otherChildren ).toHaveLength( 0 );
116
120
  } );
117
121
 
@@ -128,4 +132,91 @@ describe( 'useChartChildren', () => {
128
132
 
129
133
  expect( result.current.svgChildren ).toHaveLength( 3 );
130
134
  } );
135
+
136
+ it( 'should extract Legend children to legendChildren with default position bottom', () => {
137
+ const children = <Legend />;
138
+
139
+ const { result } = renderHook( () => useChartChildren( children, 'TestChart' ) );
140
+
141
+ expect( result.current.legendChildren ).toHaveLength( 1 );
142
+ expect( result.current.legendChildren[ 0 ].position ).toBe( 'bottom' );
143
+ expect( result.current.otherChildren ).toHaveLength( 0 );
144
+ } );
145
+
146
+ it( 'should extract Legend children with position top', () => {
147
+ const children = <Legend position="top" />;
148
+
149
+ const { result } = renderHook( () => useChartChildren( children, 'TestChart' ) );
150
+
151
+ expect( result.current.legendChildren ).toHaveLength( 1 );
152
+ expect( result.current.legendChildren[ 0 ].position ).toBe( 'top' );
153
+ expect( result.current.otherChildren ).toHaveLength( 0 );
154
+ } );
155
+
156
+ it( 'should default to bottom for invalid position values', () => {
157
+ // @ts-expect-error -- testing invalid runtime value
158
+ const children = <Legend position="left" />;
159
+
160
+ const { result } = renderHook( () => useChartChildren( children, 'TestChart' ) );
161
+
162
+ expect( result.current.legendChildren ).toHaveLength( 1 );
163
+ expect( result.current.legendChildren[ 0 ].position ).toBe( 'bottom' );
164
+ } );
165
+
166
+ it( 'should exclude Legend children from nonLegendChildren', () => {
167
+ const children = [
168
+ <Legend key="legend" position="top" />,
169
+ <div key="other">Other Content</div>,
170
+ ];
171
+
172
+ const { result } = renderHook( () => useChartChildren( children, 'TestChart' ) );
173
+
174
+ expect( result.current.legendChildren ).toHaveLength( 1 );
175
+ expect( result.current.nonLegendChildren ).toHaveLength( 1 );
176
+ } );
177
+
178
+ it( 'should preserve original child order in nonLegendChildren', () => {
179
+ const children = [
180
+ <div key="first">First</div>,
181
+ <Legend key="legend" position="top" />,
182
+ <Group key="group">
183
+ <text>SVG</text>
184
+ </Group>,
185
+ <div key="last">Last</div>,
186
+ ];
187
+
188
+ const { result } = renderHook( () => useChartChildren( children, 'TestChart' ) );
189
+
190
+ expect( result.current.nonLegendChildren ).toHaveLength( 3 );
191
+ // Verify order matches original (minus the Legend)
192
+ expect( ( result.current.nonLegendChildren[ 0 ] as React.ReactElement ).key ).toBe( 'first' );
193
+ expect( ( result.current.nonLegendChildren[ 1 ] as React.ReactElement ).key ).toBe( 'group' );
194
+ expect( ( result.current.nonLegendChildren[ 2 ] as React.ReactElement ).key ).toBe( 'last' );
195
+ } );
196
+
197
+ it( 'should return empty nonLegendChildren when all children are Legends', () => {
198
+ const children = [
199
+ <Legend key="top" position="top" />,
200
+ <Legend key="bottom" position="bottom" />,
201
+ ];
202
+
203
+ const { result } = renderHook( () => useChartChildren( children, 'TestChart' ) );
204
+
205
+ expect( result.current.legendChildren ).toHaveLength( 2 );
206
+ expect( result.current.nonLegendChildren ).toHaveLength( 0 );
207
+ } );
208
+
209
+ it( 'should extract multiple Legend children by position', () => {
210
+ const children = [
211
+ <Legend key="top" position="top" />,
212
+ <Legend key="bottom" position="bottom" />,
213
+ ];
214
+
215
+ const { result } = renderHook( () => useChartChildren( children, 'TestChart' ) );
216
+
217
+ expect( result.current.legendChildren ).toHaveLength( 2 );
218
+ expect( result.current.legendChildren[ 0 ].position ).toBe( 'top' );
219
+ expect( result.current.legendChildren[ 1 ].position ).toBe( 'bottom' );
220
+ expect( result.current.otherChildren ).toHaveLength( 0 );
221
+ } );
131
222
  } );
@@ -1,11 +1,21 @@
1
1
  import { Group } from '@visx/group';
2
2
  import { useMemo, Children, isValidElement } from 'react';
3
- import type { ReactNode } from 'react';
3
+ import { Legend } from '../../../components/legend';
4
+ import type { LegendPosition } from '../../../types';
5
+ import type { ReactElement, ReactNode } from 'react';
6
+
7
+ export type LegendChild = {
8
+ element: ReactElement;
9
+ position: LegendPosition;
10
+ };
4
11
 
5
12
  interface ChartChildren {
6
13
  svgChildren: ReactNode[];
7
14
  htmlChildren: ReactNode[];
15
+ legendChildren: LegendChild[];
8
16
  otherChildren: ReactNode[];
17
+ /** All children except Legend, in original order. */
18
+ nonLegendChildren: ReactNode[];
9
19
  }
10
20
 
11
21
  /**
@@ -21,10 +31,23 @@ export function useChartChildren( children: ReactNode, chartType: string ): Char
21
31
  return useMemo( () => {
22
32
  const svg: ReactNode[] = [];
23
33
  const html: ReactNode[] = [];
34
+ const legend: LegendChild[] = [];
24
35
  const other: ReactNode[] = [];
36
+ const nonLegend: ReactNode[] = [];
25
37
 
26
38
  Children.forEach( children, child => {
27
39
  if ( isValidElement( child ) ) {
40
+ // Extract Legend children for position-based slot rendering
41
+ if ( child.type === Legend ) {
42
+ const rawPosition = child.props?.position;
43
+ const position =
44
+ rawPosition === 'top' || rawPosition === 'bottom' ? rawPosition : 'bottom';
45
+
46
+ legend.push( { element: child as ReactElement, position } );
47
+
48
+ return;
49
+ }
50
+
28
51
  // Check displayName for compound components
29
52
  const childType = child.type as { displayName?: string };
30
53
  const displayName = childType?.displayName;
@@ -51,8 +74,17 @@ export function useChartChildren( children: ReactNode, chartType: string ): Char
51
74
  other.push( child );
52
75
  }
53
76
  }
77
+
78
+ // Preserve original order of all non-Legend children
79
+ nonLegend.push( child );
54
80
  } );
55
81
 
56
- return { svgChildren: svg, htmlChildren: html, otherChildren: other };
82
+ return {
83
+ svgChildren: svg,
84
+ htmlChildren: html,
85
+ legendChildren: legend,
86
+ otherChildren: other,
87
+ nonLegendChildren: nonLegend,
88
+ };
57
89
  }, [ children, chartType ] );
58
90
  }
@@ -1,11 +1,4 @@
1
1
  export { Legend } from './legend';
2
2
  export { useChartLegendItems } from './hooks/use-chart-legend-items';
3
- export type {
4
- LegendProps,
5
- BaseLegendProps,
6
- BaseLegendItem,
7
- LegendItemStyles,
8
- LegendLabelStyles,
9
- LegendShapeStyles,
10
- } from './types';
3
+ export type { LegendProps, BaseLegendProps, BaseLegendItem } from './types';
11
4
  export type { ChartLegendOptions, LegendValueDisplay } from './hooks/use-chart-legend-items';
@@ -1,4 +1,5 @@
1
1
  .legend {
2
+ align-self: stretch;
2
3
 
3
4
  &--horizontal {
4
5
  display: flex;
@@ -11,52 +12,33 @@
11
12
  display: flex;
12
13
  flex-direction: column;
13
14
  gap: 8px;
14
-
15
- &.legend--alignment-start {
16
- align-items: flex-start;
17
- }
18
-
19
- &.legend--alignment-center {
20
- align-items: center;
21
- }
22
-
23
- &.legend--alignment-end {
24
- align-items: flex-end;
25
- }
26
15
  }
27
16
 
28
- // Position-based styles
29
- &--position-top {
30
- position: relative;
31
-
32
- &.legend--alignment-start {
33
- justify-content: flex-start;
34
- }
35
-
36
- &.legend--alignment-center {
37
- justify-content: center;
38
- }
17
+ &--alignment-start {
18
+ justify-content: flex-start;
19
+ }
39
20
 
40
- &.legend--alignment-end {
41
- justify-content: flex-end;
42
- }
21
+ &--alignment-center {
22
+ justify-content: center;
43
23
  }
44
24
 
45
- &--position-bottom {
46
- position: relative;
25
+ &--alignment-end {
26
+ justify-content: flex-end;
27
+ }
47
28
 
48
- &.legend--alignment-start {
49
- justify-content: flex-start;
50
- }
29
+ // Vertical legends align on the cross-axis instead
30
+ &--vertical.legend--alignment-start {
31
+ align-items: flex-start;
32
+ }
51
33
 
52
- &.legend--alignment-center {
53
- justify-content: center;
54
- }
34
+ &--vertical.legend--alignment-center {
35
+ align-items: center;
36
+ }
55
37
 
56
- &.legend--alignment-end {
57
- justify-content: flex-end;
58
- }
38
+ &--vertical.legend--alignment-end {
39
+ align-items: flex-end;
59
40
  }
41
+
60
42
  }
61
43
 
62
44
  .legend-item {
@@ -67,7 +67,6 @@ export const BaseLegend: ForwardRefExoticComponent<
67
67
  items,
68
68
  className,
69
69
  orientation = 'horizontal',
70
- position = 'bottom',
71
70
  alignment = 'center',
72
71
  shape = 'rect',
73
72
  fill = valueOrIdentityString,
@@ -177,7 +176,6 @@ export const BaseLegend: ForwardRefExoticComponent<
177
176
  styles.legend,
178
177
  styles[ `legend--${ orientation }` ],
179
178
  styles[ `legend--alignment-${ alignment }` ],
180
- styles[ `legend--position-${ position }` ],
181
179
  className
182
180
  ) }
183
181
  style={ {