@automattic/charts 0.57.0 → 0.59.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 (267) hide show
  1. package/CHANGELOG.md +36 -2
  2. package/README.md +7 -54
  3. package/dist/index.cjs +9607 -21
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +32 -49
  6. package/dist/index.css.map +1 -1
  7. package/dist/index.d.cts +1612 -33
  8. package/dist/index.d.ts +1612 -33
  9. package/dist/index.js +9640 -54
  10. package/dist/index.js.map +1 -1
  11. package/package.json +9 -126
  12. package/src/charts/bar-chart/bar-chart.module.scss +0 -5
  13. package/src/charts/bar-chart/bar-chart.tsx +142 -149
  14. package/src/charts/bar-chart/test/bar-chart.test.tsx +48 -31
  15. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +54 -74
  16. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +4 -5
  17. package/src/charts/leaderboard-chart/types.ts +1 -11
  18. package/src/charts/line-chart/line-chart.module.scss +0 -5
  19. package/src/charts/line-chart/line-chart.tsx +202 -193
  20. package/src/charts/line-chart/private/line-chart-annotations-overlay.tsx +1 -2
  21. package/src/charts/line-chart/test/line-chart.test.tsx +49 -27
  22. package/src/charts/line-chart/types.ts +0 -1
  23. package/src/charts/pie-chart/pie-chart.module.scss +2 -10
  24. package/src/charts/pie-chart/pie-chart.tsx +212 -212
  25. package/src/charts/pie-chart/test/composition-api.test.tsx +44 -3
  26. package/src/charts/pie-chart/test/pie-chart.test.tsx +51 -44
  27. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +2 -8
  28. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +166 -168
  29. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +58 -30
  30. package/src/charts/private/chart-composition/index.ts +2 -0
  31. package/src/charts/private/chart-composition/render-legend-slot.ts +22 -0
  32. package/src/charts/private/chart-composition/test/render-legend-slot.test.tsx +60 -0
  33. package/src/charts/private/chart-composition/test/use-chart-children.test.tsx +91 -0
  34. package/src/charts/private/chart-composition/use-chart-children.ts +34 -2
  35. package/src/charts/private/chart-layout/chart-layout.module.scss +7 -0
  36. package/src/charts/private/chart-layout/chart-layout.tsx +106 -0
  37. package/src/charts/private/chart-layout/index.ts +2 -0
  38. package/src/charts/private/chart-layout/test/chart-layout.test.tsx +167 -0
  39. package/src/charts/private/single-chart-context/single-chart-context.tsx +2 -2
  40. package/src/charts/private/svg-empty-state/index.ts +1 -0
  41. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +7 -0
  42. package/src/charts/private/svg-empty-state/svg-empty-state.tsx +40 -0
  43. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +12 -8
  44. package/src/components/legend/hooks/use-chart-legend-items.ts +12 -13
  45. package/src/components/legend/index.ts +1 -8
  46. package/src/components/legend/legend.tsx +33 -8
  47. package/src/components/legend/private/base-legend.module.scss +19 -37
  48. package/src/components/legend/private/base-legend.tsx +0 -2
  49. package/src/components/legend/test/legend.test.tsx +93 -1
  50. package/src/components/legend/types.ts +7 -34
  51. package/src/hooks/index.ts +1 -1
  52. package/src/hooks/use-data-with-percentages.ts +24 -0
  53. package/src/hooks/use-interactive-legend-data.ts +18 -15
  54. package/src/index.ts +66 -9
  55. package/src/providers/chart-context/global-charts-provider.tsx +7 -1
  56. package/src/providers/chart-context/hooks/use-chart-registration.ts +2 -1
  57. package/src/providers/chart-context/types.ts +2 -2
  58. package/src/types.ts +110 -45
  59. package/src/utils/date-parsing.ts +10 -1
  60. package/src/utils/test/date-parsing.test.ts +12 -0
  61. package/src/utils/test/resolve-css-var.test.ts +4 -2
  62. package/tsup.config.ts +1 -1
  63. package/dist/base-tooltip-DOq93wjU.d.cts +0 -38
  64. package/dist/base-tooltip-DOq93wjU.d.ts +0 -38
  65. package/dist/charts/bar-chart/index.cjs +0 -15
  66. package/dist/charts/bar-chart/index.cjs.map +0 -1
  67. package/dist/charts/bar-chart/index.css +0 -153
  68. package/dist/charts/bar-chart/index.css.map +0 -1
  69. package/dist/charts/bar-chart/index.d.cts +0 -37
  70. package/dist/charts/bar-chart/index.d.ts +0 -37
  71. package/dist/charts/bar-chart/index.js +0 -15
  72. package/dist/charts/bar-chart/index.js.map +0 -1
  73. package/dist/charts/bar-list-chart/index.cjs +0 -16
  74. package/dist/charts/bar-list-chart/index.cjs.map +0 -1
  75. package/dist/charts/bar-list-chart/index.css +0 -153
  76. package/dist/charts/bar-list-chart/index.css.map +0 -1
  77. package/dist/charts/bar-list-chart/index.d.cts +0 -92
  78. package/dist/charts/bar-list-chart/index.d.ts +0 -92
  79. package/dist/charts/bar-list-chart/index.js +0 -16
  80. package/dist/charts/bar-list-chart/index.js.map +0 -1
  81. package/dist/charts/conversion-funnel-chart/index.cjs +0 -11
  82. package/dist/charts/conversion-funnel-chart/index.cjs.map +0 -1
  83. package/dist/charts/conversion-funnel-chart/index.css +0 -251
  84. package/dist/charts/conversion-funnel-chart/index.css.map +0 -1
  85. package/dist/charts/conversion-funnel-chart/index.d.cts +0 -97
  86. package/dist/charts/conversion-funnel-chart/index.d.ts +0 -97
  87. package/dist/charts/conversion-funnel-chart/index.js +0 -11
  88. package/dist/charts/conversion-funnel-chart/index.js.map +0 -1
  89. package/dist/charts/geo-chart/index.cjs +0 -13
  90. package/dist/charts/geo-chart/index.cjs.map +0 -1
  91. package/dist/charts/geo-chart/index.css +0 -117
  92. package/dist/charts/geo-chart/index.css.map +0 -1
  93. package/dist/charts/geo-chart/index.d.cts +0 -67
  94. package/dist/charts/geo-chart/index.d.ts +0 -67
  95. package/dist/charts/geo-chart/index.js +0 -13
  96. package/dist/charts/geo-chart/index.js.map +0 -1
  97. package/dist/charts/leaderboard-chart/index.cjs +0 -20
  98. package/dist/charts/leaderboard-chart/index.cjs.map +0 -1
  99. package/dist/charts/leaderboard-chart/index.css +0 -172
  100. package/dist/charts/leaderboard-chart/index.css.map +0 -1
  101. package/dist/charts/leaderboard-chart/index.d.cts +0 -46
  102. package/dist/charts/leaderboard-chart/index.d.ts +0 -46
  103. package/dist/charts/leaderboard-chart/index.js +0 -20
  104. package/dist/charts/leaderboard-chart/index.js.map +0 -1
  105. package/dist/charts/line-chart/index.cjs +0 -15
  106. package/dist/charts/line-chart/index.cjs.map +0 -1
  107. package/dist/charts/line-chart/index.css +0 -225
  108. package/dist/charts/line-chart/index.css.map +0 -1
  109. package/dist/charts/line-chart/index.d.cts +0 -99
  110. package/dist/charts/line-chart/index.d.ts +0 -99
  111. package/dist/charts/line-chart/index.js +0 -15
  112. package/dist/charts/line-chart/index.js.map +0 -1
  113. package/dist/charts/pie-chart/index.cjs +0 -18
  114. package/dist/charts/pie-chart/index.cjs.map +0 -1
  115. package/dist/charts/pie-chart/index.css +0 -143
  116. package/dist/charts/pie-chart/index.css.map +0 -1
  117. package/dist/charts/pie-chart/index.d.cts +0 -97
  118. package/dist/charts/pie-chart/index.d.ts +0 -97
  119. package/dist/charts/pie-chart/index.js +0 -18
  120. package/dist/charts/pie-chart/index.js.map +0 -1
  121. package/dist/charts/pie-semi-circle-chart/index.cjs +0 -17
  122. package/dist/charts/pie-semi-circle-chart/index.cjs.map +0 -1
  123. package/dist/charts/pie-semi-circle-chart/index.css +0 -144
  124. package/dist/charts/pie-semi-circle-chart/index.css.map +0 -1
  125. package/dist/charts/pie-semi-circle-chart/index.d.cts +0 -94
  126. package/dist/charts/pie-semi-circle-chart/index.d.ts +0 -94
  127. package/dist/charts/pie-semi-circle-chart/index.js +0 -17
  128. package/dist/charts/pie-semi-circle-chart/index.js.map +0 -1
  129. package/dist/charts/sparkline/index.cjs +0 -16
  130. package/dist/charts/sparkline/index.cjs.map +0 -1
  131. package/dist/charts/sparkline/index.css +0 -242
  132. package/dist/charts/sparkline/index.css.map +0 -1
  133. package/dist/charts/sparkline/index.d.cts +0 -113
  134. package/dist/charts/sparkline/index.d.ts +0 -113
  135. package/dist/charts/sparkline/index.js +0 -16
  136. package/dist/charts/sparkline/index.js.map +0 -1
  137. package/dist/chunk-2A34OA5O.cjs +0 -51
  138. package/dist/chunk-2A34OA5O.cjs.map +0 -1
  139. package/dist/chunk-2NCY7R4G.js +0 -3897
  140. package/dist/chunk-2NCY7R4G.js.map +0 -1
  141. package/dist/chunk-32DH6JDF.js +0 -1263
  142. package/dist/chunk-32DH6JDF.js.map +0 -1
  143. package/dist/chunk-4OPFE4RM.js +0 -614
  144. package/dist/chunk-4OPFE4RM.js.map +0 -1
  145. package/dist/chunk-6CCZL2JJ.js +0 -63
  146. package/dist/chunk-6CCZL2JJ.js.map +0 -1
  147. package/dist/chunk-77OKCVQN.cjs +0 -421
  148. package/dist/chunk-77OKCVQN.cjs.map +0 -1
  149. package/dist/chunk-7FQX4ALL.cjs +0 -219
  150. package/dist/chunk-7FQX4ALL.cjs.map +0 -1
  151. package/dist/chunk-ASLARV7L.cjs +0 -81
  152. package/dist/chunk-ASLARV7L.cjs.map +0 -1
  153. package/dist/chunk-BCX5THDQ.js +0 -403
  154. package/dist/chunk-BCX5THDQ.js.map +0 -1
  155. package/dist/chunk-BPYKWMI7.js +0 -194
  156. package/dist/chunk-BPYKWMI7.js.map +0 -1
  157. package/dist/chunk-CZGYJKG6.js +0 -421
  158. package/dist/chunk-CZGYJKG6.js.map +0 -1
  159. package/dist/chunk-D2UH4CFE.cjs +0 -120
  160. package/dist/chunk-D2UH4CFE.cjs.map +0 -1
  161. package/dist/chunk-DAU3HNEG.js +0 -344
  162. package/dist/chunk-DAU3HNEG.js.map +0 -1
  163. package/dist/chunk-H2V4JMSA.js +0 -219
  164. package/dist/chunk-H2V4JMSA.js.map +0 -1
  165. package/dist/chunk-I2276W3I.cjs +0 -66
  166. package/dist/chunk-I2276W3I.cjs.map +0 -1
  167. package/dist/chunk-I35UYJJR.cjs +0 -468
  168. package/dist/chunk-I35UYJJR.cjs.map +0 -1
  169. package/dist/chunk-IU4DYUAV.js +0 -120
  170. package/dist/chunk-IU4DYUAV.js.map +0 -1
  171. package/dist/chunk-KXRWNFQJ.js +0 -51
  172. package/dist/chunk-KXRWNFQJ.js.map +0 -1
  173. package/dist/chunk-OP6PHB2U.js +0 -81
  174. package/dist/chunk-OP6PHB2U.js.map +0 -1
  175. package/dist/chunk-PXLEMUGJ.js +0 -165
  176. package/dist/chunk-PXLEMUGJ.js.map +0 -1
  177. package/dist/chunk-RCY6XLGU.cjs +0 -63
  178. package/dist/chunk-RCY6XLGU.cjs.map +0 -1
  179. package/dist/chunk-RHHVEJHJ.cjs +0 -1263
  180. package/dist/chunk-RHHVEJHJ.cjs.map +0 -1
  181. package/dist/chunk-TO3OQBXG.cjs +0 -165
  182. package/dist/chunk-TO3OQBXG.cjs.map +0 -1
  183. package/dist/chunk-V36ERY7Y.js +0 -375
  184. package/dist/chunk-V36ERY7Y.js.map +0 -1
  185. package/dist/chunk-VJM5XCB4.cjs +0 -614
  186. package/dist/chunk-VJM5XCB4.cjs.map +0 -1
  187. package/dist/chunk-VTS3PNMS.cjs +0 -344
  188. package/dist/chunk-VTS3PNMS.cjs.map +0 -1
  189. package/dist/chunk-WLODYNLB.js +0 -1067
  190. package/dist/chunk-WLODYNLB.js.map +0 -1
  191. package/dist/chunk-XKRJL2QT.cjs +0 -375
  192. package/dist/chunk-XKRJL2QT.cjs.map +0 -1
  193. package/dist/chunk-XWYZIFZW.js +0 -66
  194. package/dist/chunk-XWYZIFZW.js.map +0 -1
  195. package/dist/chunk-Y3NNQMAX.cjs +0 -194
  196. package/dist/chunk-Y3NNQMAX.cjs.map +0 -1
  197. package/dist/chunk-YE2T52VZ.cjs +0 -1067
  198. package/dist/chunk-YE2T52VZ.cjs.map +0 -1
  199. package/dist/chunk-Z26M4V2M.js +0 -468
  200. package/dist/chunk-Z26M4V2M.js.map +0 -1
  201. package/dist/chunk-Z45KX47P.cjs +0 -3897
  202. package/dist/chunk-Z45KX47P.cjs.map +0 -1
  203. package/dist/chunk-ZH4F5RMG.cjs +0 -403
  204. package/dist/chunk-ZH4F5RMG.cjs.map +0 -1
  205. package/dist/components/legend/index.cjs +0 -11
  206. package/dist/components/legend/index.cjs.map +0 -1
  207. package/dist/components/legend/index.css +0 -103
  208. package/dist/components/legend/index.css.map +0 -1
  209. package/dist/components/legend/index.d.cts +0 -37
  210. package/dist/components/legend/index.d.ts +0 -37
  211. package/dist/components/legend/index.js +0 -11
  212. package/dist/components/legend/index.js.map +0 -1
  213. package/dist/components/tooltip/index.cjs +0 -12
  214. package/dist/components/tooltip/index.cjs.map +0 -1
  215. package/dist/components/tooltip/index.css +0 -13
  216. package/dist/components/tooltip/index.css.map +0 -1
  217. package/dist/components/tooltip/index.d.cts +0 -71
  218. package/dist/components/tooltip/index.d.ts +0 -71
  219. package/dist/components/tooltip/index.js +0 -12
  220. package/dist/components/tooltip/index.js.map +0 -1
  221. package/dist/components/trend-indicator/index.cjs +0 -8
  222. package/dist/components/trend-indicator/index.cjs.map +0 -1
  223. package/dist/components/trend-indicator/index.css +0 -27
  224. package/dist/components/trend-indicator/index.css.map +0 -1
  225. package/dist/components/trend-indicator/index.d.cts +0 -44
  226. package/dist/components/trend-indicator/index.d.ts +0 -44
  227. package/dist/components/trend-indicator/index.js +0 -8
  228. package/dist/components/trend-indicator/index.js.map +0 -1
  229. package/dist/format-metric-value-MXm5DtQ_.d.cts +0 -24
  230. package/dist/format-metric-value-MXm5DtQ_.d.ts +0 -24
  231. package/dist/hooks/index.cjs +0 -31
  232. package/dist/hooks/index.cjs.map +0 -1
  233. package/dist/hooks/index.css +0 -103
  234. package/dist/hooks/index.css.map +0 -1
  235. package/dist/hooks/index.d.cts +0 -272
  236. package/dist/hooks/index.d.ts +0 -272
  237. package/dist/hooks/index.js +0 -31
  238. package/dist/hooks/index.js.map +0 -1
  239. package/dist/leaderboard-chart-BKYYXcg2.d.ts +0 -83
  240. package/dist/leaderboard-chart-DR7CGb0L.d.cts +0 -83
  241. package/dist/legend-C2grwnWk.d.cts +0 -9
  242. package/dist/legend-Cj0xM5dU.d.ts +0 -9
  243. package/dist/providers/index.cjs +0 -21
  244. package/dist/providers/index.cjs.map +0 -1
  245. package/dist/providers/index.css +0 -103
  246. package/dist/providers/index.css.map +0 -1
  247. package/dist/providers/index.d.cts +0 -28
  248. package/dist/providers/index.d.ts +0 -28
  249. package/dist/providers/index.js +0 -21
  250. package/dist/providers/index.js.map +0 -1
  251. package/dist/themes-BmVGrYnF.d.ts +0 -67
  252. package/dist/themes-CyjKm-P_.d.cts +0 -67
  253. package/dist/types-CuUEszrM.d.ts +0 -19
  254. package/dist/types-DZordNiO.d.cts +0 -505
  255. package/dist/types-DZordNiO.d.ts +0 -505
  256. package/dist/types-I67mddpr.d.cts +0 -78
  257. package/dist/types-I67mddpr.d.ts +0 -78
  258. package/dist/types-KtOPPzPX.d.cts +0 -19
  259. package/dist/utils/index.cjs +0 -44
  260. package/dist/utils/index.cjs.map +0 -1
  261. package/dist/utils/index.d.cts +0 -226
  262. package/dist/utils/index.d.ts +0 -226
  263. package/dist/utils/index.js +0 -44
  264. package/dist/utils/index.js.map +0 -1
  265. package/dist/with-responsive-CNfhzAUu.d.cts +0 -18
  266. package/dist/with-responsive-CNfhzAUu.d.ts +0 -18
  267. package/src/hooks/use-has-legend-child.ts +0 -22
@@ -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
  }
@@ -0,0 +1,7 @@
1
+ // Shared content area for charts using the render prop.
2
+ // Wraps render prop output and handles measurement.
3
+ .chart-layout__content {
4
+ flex: 1;
5
+ min-height: 0;
6
+ min-width: 0;
7
+ }
@@ -0,0 +1,106 @@
1
+ import { Stack } from '@wordpress/ui';
2
+ import { useEffect } from 'react';
3
+ import { useElementSize } from '../../../hooks';
4
+ import { renderLegendSlot } from '../chart-composition';
5
+ import styles from './chart-layout.module.scss';
6
+ import type { LegendPosition } from '../../../types';
7
+ import type { LegendChild } from '../chart-composition/use-chart-children';
8
+ import type { GapSize } from '@wordpress/theme';
9
+ import type { CSSProperties, ReactNode } from 'react';
10
+
11
+ /**
12
+ * Measurements provided to the render prop when ChartLayout handles resize listening.
13
+ */
14
+ export interface ContentMeasurements {
15
+ /** Measured width of the content area in pixels */
16
+ contentWidth: number;
17
+ /** Measured height of the content area in pixels */
18
+ contentHeight: number;
19
+ /** True when a non-zero contentHeight measurement is available */
20
+ isMeasured: boolean;
21
+ }
22
+
23
+ export interface ChartLayoutProps {
24
+ /** Position for the prop-based legend element */
25
+ legendPosition: LegendPosition;
26
+ /** The legend element rendered via the showLegend prop (false when hidden) */
27
+ legendElement?: ReactNode;
28
+ /** Legend children from the composition API */
29
+ legendChildren: LegendChild[];
30
+ /** Chart content — either a ReactNode or a render prop receiving content measurements */
31
+ children: ReactNode | ( ( measurements: ContentMeasurements ) => ReactNode );
32
+ /** Content rendered after the bottom legend (e.g., nonLegendChildren, htmlChildren, tooltips) */
33
+ trailingContent?: ReactNode;
34
+ /** Called when the measured content height changes (for render-prop mode) */
35
+ onContentHeightChange?: ( height: number ) => void;
36
+ /** Gap between Stack items */
37
+ gap?: GapSize;
38
+ /** Additional class names */
39
+ className?: string;
40
+ /** Inline styles (width, height, etc.) */
41
+ style?: CSSProperties;
42
+ /** Test ID for the container */
43
+ 'data-testid'?: string;
44
+ /** Chart ID attribute */
45
+ 'data-chart-id'?: string;
46
+ }
47
+
48
+ export const ChartLayout = ( {
49
+ legendPosition,
50
+ legendElement,
51
+ legendChildren,
52
+ children,
53
+ trailingContent,
54
+ onContentHeightChange,
55
+ gap,
56
+ className,
57
+ style,
58
+ 'data-testid': dataTestId,
59
+ 'data-chart-id': dataChartId,
60
+ }: ChartLayoutProps ) => {
61
+ const [ contentRef, contentWidth, contentHeight ] = useElementSize< HTMLDivElement >();
62
+ const isRenderProp = typeof children === 'function';
63
+ const isMeasured = contentHeight > 0;
64
+
65
+ // When using render-prop children, hide the layout until measurement is available
66
+ // to prevent layout shift. Plain ReactNode children don't need this since they
67
+ // don't depend on measured dimensions.
68
+ const visibilityStyle: { visibility?: 'hidden' | 'visible' } =
69
+ isRenderProp && ! isMeasured ? { visibility: 'hidden' } : {};
70
+
71
+ useEffect( () => {
72
+ if ( isRenderProp && onContentHeightChange && isMeasured ) {
73
+ onContentHeightChange( contentHeight );
74
+ }
75
+ }, [ isRenderProp, contentHeight, isMeasured, onContentHeightChange ] );
76
+ const renderedChildren = isRenderProp
77
+ ? children( { contentWidth, contentHeight, isMeasured } )
78
+ : children;
79
+
80
+ return (
81
+ <Stack
82
+ direction="column"
83
+ gap={ gap }
84
+ className={ className }
85
+ style={ { ...style, ...visibilityStyle } }
86
+ data-testid={ dataTestId }
87
+ data-chart-id={ dataChartId }
88
+ >
89
+ { legendPosition === 'top' && legendElement }
90
+ { renderLegendSlot( legendChildren, 'top' ) }
91
+
92
+ { isRenderProp ? (
93
+ <div ref={ contentRef } className={ styles[ 'chart-layout__content' ] }>
94
+ { renderedChildren }
95
+ </div>
96
+ ) : (
97
+ renderedChildren
98
+ ) }
99
+
100
+ { legendPosition === 'bottom' && legendElement }
101
+ { renderLegendSlot( legendChildren, 'bottom' ) }
102
+
103
+ { trailingContent }
104
+ </Stack>
105
+ );
106
+ };
@@ -0,0 +1,2 @@
1
+ export { ChartLayout } from './chart-layout';
2
+ export type { ChartLayoutProps, ContentMeasurements } from './chart-layout';
@@ -0,0 +1,167 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { renderLegendSlot } from '../../chart-composition';
3
+ import { ChartLayout } from '../chart-layout';
4
+ import type { LegendChild } from '../../chart-composition/use-chart-children';
5
+
6
+ // Mock renderLegendSlot since we test it separately
7
+ jest.mock( '../../chart-composition', () => ( {
8
+ renderLegendSlot: jest.fn( () => [] ),
9
+ } ) );
10
+
11
+ const mockRenderLegendSlot = renderLegendSlot as jest.Mock;
12
+
13
+ describe( 'ChartLayout', () => {
14
+ beforeEach( () => {
15
+ mockRenderLegendSlot.mockReturnValue( [] );
16
+ } );
17
+
18
+ it( 'renders children inside a column Stack', () => {
19
+ render(
20
+ <ChartLayout legendPosition="bottom" legendChildren={ [] }>
21
+ <div data-testid="chart-content">Chart</div>
22
+ </ChartLayout>
23
+ );
24
+ expect( screen.getByTestId( 'chart-content' ) ).toBeInTheDocument();
25
+ } );
26
+
27
+ it( 'renders legend element at top when legendPosition is top', () => {
28
+ const legendElement = <div data-testid="legend">Legend</div>;
29
+ render(
30
+ <ChartLayout legendPosition="top" legendElement={ legendElement } legendChildren={ [] }>
31
+ <div data-testid="chart-content">Chart</div>
32
+ </ChartLayout>
33
+ );
34
+ const legend = screen.getByTestId( 'legend' );
35
+ const content = screen.getByTestId( 'chart-content' );
36
+ expect( legend.compareDocumentPosition( content ) ).toBe( Node.DOCUMENT_POSITION_FOLLOWING );
37
+ } );
38
+
39
+ it( 'renders legend element at bottom when legendPosition is bottom', () => {
40
+ const legendElement = <div data-testid="legend">Legend</div>;
41
+ render(
42
+ <ChartLayout legendPosition="bottom" legendElement={ legendElement } legendChildren={ [] }>
43
+ <div data-testid="chart-content">Chart</div>
44
+ </ChartLayout>
45
+ );
46
+ const content = screen.getByTestId( 'chart-content' );
47
+ const legend = screen.getByTestId( 'legend' );
48
+ expect( content.compareDocumentPosition( legend ) ).toBe( Node.DOCUMENT_POSITION_FOLLOWING );
49
+ } );
50
+
51
+ it( 'does not render legend element when it is false/null', () => {
52
+ render(
53
+ <ChartLayout legendPosition="top" legendElement={ false } legendChildren={ [] }>
54
+ <div data-testid="chart-content">Chart</div>
55
+ </ChartLayout>
56
+ );
57
+ expect( screen.queryByTestId( 'legend' ) ).not.toBeInTheDocument();
58
+ } );
59
+
60
+ it( 'calls renderLegendSlot for both positions', () => {
61
+ const legendChildren: LegendChild[] = [];
62
+ render(
63
+ <ChartLayout legendPosition="bottom" legendChildren={ legendChildren }>
64
+ <div>Chart</div>
65
+ </ChartLayout>
66
+ );
67
+ expect( mockRenderLegendSlot ).toHaveBeenCalledWith( legendChildren, 'top' );
68
+ expect( mockRenderLegendSlot ).toHaveBeenCalledWith( legendChildren, 'bottom' );
69
+ } );
70
+
71
+ it( 'hides layout until measured when using render-prop children', () => {
72
+ // Override the global getBoundingClientRect mock to return zero (unmeasured state)
73
+ // for elements inside this test. This simulates the initial state before
74
+ // ResizeObserver provides real dimensions in a browser.
75
+ const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
76
+ Element.prototype.getBoundingClientRect = function () {
77
+ return { width: 0, height: 0, top: 0, left: 0, bottom: 0, right: 0, x: 0, y: 0 } as DOMRect;
78
+ };
79
+
80
+ try {
81
+ const childFn = jest.fn().mockReturnValue( <div>Chart</div> );
82
+ render(
83
+ <ChartLayout legendPosition="bottom" legendChildren={ [] } data-testid="layout">
84
+ { childFn }
85
+ </ChartLayout>
86
+ );
87
+ // When contentHeight is 0, layout should be hidden to prevent layout shift
88
+ expect( screen.getByTestId( 'layout' ) ).toHaveStyle( { visibility: 'hidden' } );
89
+ } finally {
90
+ Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
91
+ }
92
+ } );
93
+
94
+ it( 'does not hide layout when using plain ReactNode children', () => {
95
+ render(
96
+ <ChartLayout legendPosition="bottom" legendChildren={ [] } data-testid="layout">
97
+ <div>Chart</div>
98
+ </ChartLayout>
99
+ );
100
+ const layoutStyle = screen.getByTestId( 'layout' ).getAttribute( 'style' ) ?? '';
101
+ expect( layoutStyle ).not.toContain( 'visibility' );
102
+ } );
103
+
104
+ it( 'passes className and style to Stack', () => {
105
+ render(
106
+ <ChartLayout
107
+ legendPosition="bottom"
108
+ legendChildren={ [] }
109
+ className="my-chart"
110
+ style={ { width: 400, height: 300 } }
111
+ data-testid="layout"
112
+ >
113
+ <div>Chart</div>
114
+ </ChartLayout>
115
+ );
116
+ const layout = screen.getByTestId( 'layout' );
117
+ expect( layout ).toHaveClass( 'my-chart' );
118
+ expect( layout ).toHaveStyle( { width: '400px', height: '300px' } );
119
+ } );
120
+
121
+ it( 'passes gap to Stack', () => {
122
+ render(
123
+ <ChartLayout legendPosition="bottom" legendChildren={ [] } gap="lg" data-testid="layout">
124
+ <div>Chart</div>
125
+ </ChartLayout>
126
+ );
127
+ // Stack renders gap as a CSS class or style — just verify it renders without error
128
+ expect( screen.getByTestId( 'layout' ) ).toBeInTheDocument();
129
+ } );
130
+
131
+ it( 'calls function-as-children with measurement props', () => {
132
+ const childFn = jest.fn().mockReturnValue( <div data-testid="chart-content">Chart</div> );
133
+
134
+ render(
135
+ <ChartLayout legendPosition="bottom" legendChildren={ [] }>
136
+ { childFn }
137
+ </ChartLayout>
138
+ );
139
+
140
+ expect( childFn ).toHaveBeenCalled();
141
+ const firstCallArg = childFn.mock.calls[ 0 ][ 0 ];
142
+
143
+ expect( firstCallArg ).toEqual(
144
+ expect.objectContaining( {
145
+ contentWidth: expect.any( Number ),
146
+ contentHeight: expect.any( Number ),
147
+ isMeasured: expect.any( Boolean ),
148
+ } )
149
+ );
150
+ } );
151
+
152
+ it( 'renders trailing content after bottom legend', () => {
153
+ render(
154
+ <ChartLayout
155
+ legendPosition="bottom"
156
+ legendElement={ <div data-testid="legend">Legend</div> }
157
+ legendChildren={ [] }
158
+ trailingContent={ <div data-testid="trailing">Extra</div> }
159
+ >
160
+ <div data-testid="chart-content">Chart</div>
161
+ </ChartLayout>
162
+ );
163
+ const legend = screen.getByTestId( 'legend' );
164
+ const trailing = screen.getByTestId( 'trailing' );
165
+ expect( legend.compareDocumentPosition( trailing ) ).toBe( Node.DOCUMENT_POSITION_FOLLOWING );
166
+ } );
167
+ } );
@@ -13,8 +13,8 @@ export interface ChartInstanceRef {
13
13
  export interface ChartInstanceContextValue {
14
14
  chartId: string;
15
15
  chartRef?: React.RefObject< ChartInstanceRef >;
16
- chartWidth: number;
17
- chartHeight: number;
16
+ chartWidth?: number;
17
+ chartHeight?: number;
18
18
  }
19
19
 
20
20
  export const ChartInstanceContext = createContext< ChartInstanceContextValue | null >( null );
@@ -0,0 +1 @@
1
+ export { SvgEmptyState } from './svg-empty-state';
@@ -0,0 +1,7 @@
1
+ .svg-empty-state {
2
+ text-align: center;
3
+ width: 100%;
4
+ height: 100%;
5
+ font-size: var(--wpds-font-size-md, 13px);
6
+ color: var(--wpds-color-fg-content-neutral-weak, #6d6d6d);
7
+ }
@@ -0,0 +1,40 @@
1
+ import { Stack } from '@wordpress/ui';
2
+ import styles from './svg-empty-state.module.scss';
3
+ import type { FC, ReactNode } from 'react';
4
+
5
+ interface SvgEmptyStateProps {
6
+ /** X coordinate of the center point */
7
+ x: number;
8
+ /** Y coordinate of the center point */
9
+ y: number;
10
+ /** Available width for the text area */
11
+ width: number;
12
+ /** Available height for the text area */
13
+ height: number;
14
+ /** Text content */
15
+ children: ReactNode;
16
+ }
17
+
18
+ /**
19
+ * Renders empty-state text inside an SVG using foreignObject so that the
20
+ * message wraps onto multiple lines instead of being clipped.
21
+ *
22
+ * The component centers the text within the specified area.
23
+ *
24
+ * @param root0 - Component props
25
+ * @param root0.x - X coordinate of the center point
26
+ * @param root0.y - Y coordinate of the center point
27
+ * @param root0.width - Available width for the text area
28
+ * @param root0.height - Available height for the text area
29
+ * @param root0.children - Text content
30
+ * @return {JSX.Element} A foreignObject element containing the centered text.
31
+ */
32
+ export const SvgEmptyState: FC< SvgEmptyStateProps > = ( { x, y, width, height, children } ) => {
33
+ return (
34
+ <foreignObject x={ x - width / 2 } y={ y - height / 2 } width={ width } height={ height }>
35
+ <Stack align="center" justify="center" className={ styles[ 'svg-empty-state' ] }>
36
+ { children }
37
+ </Stack>
38
+ </foreignObject>
39
+ );
40
+ };