@automattic/charts 0.56.7 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/AGENTS.md +28 -98
  2. package/CHANGELOG.md +30 -0
  3. package/dist/charts/bar-chart/index.cjs +7 -6
  4. package/dist/charts/bar-chart/index.cjs.map +1 -1
  5. package/dist/charts/bar-chart/index.css +12 -24
  6. package/dist/charts/bar-chart/index.css.map +1 -1
  7. package/dist/charts/bar-chart/index.d.cts +3 -4
  8. package/dist/charts/bar-chart/index.d.ts +3 -4
  9. package/dist/charts/bar-chart/index.js +6 -5
  10. package/dist/charts/bar-list-chart/index.cjs +8 -7
  11. package/dist/charts/bar-list-chart/index.cjs.map +1 -1
  12. package/dist/charts/bar-list-chart/index.css +12 -24
  13. package/dist/charts/bar-list-chart/index.css.map +1 -1
  14. package/dist/charts/bar-list-chart/index.d.cts +3 -3
  15. package/dist/charts/bar-list-chart/index.d.ts +3 -3
  16. package/dist/charts/bar-list-chart/index.js +7 -6
  17. package/dist/charts/conversion-funnel-chart/index.cjs +5 -6
  18. package/dist/charts/conversion-funnel-chart/index.cjs.map +1 -1
  19. package/dist/charts/conversion-funnel-chart/index.css +0 -94
  20. package/dist/charts/conversion-funnel-chart/index.css.map +1 -1
  21. package/dist/charts/conversion-funnel-chart/index.d.cts +1 -1
  22. package/dist/charts/conversion-funnel-chart/index.d.ts +1 -1
  23. package/dist/charts/conversion-funnel-chart/index.js +4 -5
  24. package/dist/charts/geo-chart/index.cjs +4 -4
  25. package/dist/charts/geo-chart/index.css +0 -94
  26. package/dist/charts/geo-chart/index.css.map +1 -1
  27. package/dist/charts/geo-chart/index.d.cts +1 -1
  28. package/dist/charts/geo-chart/index.d.ts +1 -1
  29. package/dist/charts/geo-chart/index.js +3 -3
  30. package/dist/charts/leaderboard-chart/index.cjs +7 -6
  31. package/dist/charts/leaderboard-chart/index.cjs.map +1 -1
  32. package/dist/charts/leaderboard-chart/index.css +20 -33
  33. package/dist/charts/leaderboard-chart/index.css.map +1 -1
  34. package/dist/charts/leaderboard-chart/index.d.cts +3 -3
  35. package/dist/charts/leaderboard-chart/index.d.ts +3 -3
  36. package/dist/charts/leaderboard-chart/index.js +6 -5
  37. package/dist/charts/line-chart/index.cjs +7 -6
  38. package/dist/charts/line-chart/index.cjs.map +1 -1
  39. package/dist/charts/line-chart/index.css +12 -24
  40. package/dist/charts/line-chart/index.css.map +1 -1
  41. package/dist/charts/line-chart/index.d.cts +3 -4
  42. package/dist/charts/line-chart/index.d.ts +3 -4
  43. package/dist/charts/line-chart/index.js +6 -5
  44. package/dist/charts/pie-chart/index.cjs +7 -7
  45. package/dist/charts/pie-chart/index.css +12 -24
  46. package/dist/charts/pie-chart/index.css.map +1 -1
  47. package/dist/charts/pie-chart/index.d.cts +7 -13
  48. package/dist/charts/pie-chart/index.d.ts +7 -13
  49. package/dist/charts/pie-chart/index.js +6 -6
  50. package/dist/charts/pie-semi-circle-chart/index.cjs +7 -7
  51. package/dist/charts/pie-semi-circle-chart/index.css +12 -24
  52. package/dist/charts/pie-semi-circle-chart/index.css.map +1 -1
  53. package/dist/charts/pie-semi-circle-chart/index.d.cts +7 -13
  54. package/dist/charts/pie-semi-circle-chart/index.d.ts +7 -13
  55. package/dist/charts/pie-semi-circle-chart/index.js +6 -6
  56. package/dist/charts/sparkline/index.cjs +8 -7
  57. package/dist/charts/sparkline/index.cjs.map +1 -1
  58. package/dist/charts/sparkline/index.css +12 -24
  59. package/dist/charts/sparkline/index.css.map +1 -1
  60. package/dist/charts/sparkline/index.js +7 -6
  61. package/dist/{chunk-RFSHE3HL.js → chunk-2I67QUIV.js} +84 -431
  62. package/dist/chunk-2I67QUIV.js.map +1 -0
  63. package/dist/{chunk-OMS5QIJN.js → chunk-2ICEEQOC.js} +31 -25
  64. package/dist/chunk-2ICEEQOC.js.map +1 -0
  65. package/dist/{chunk-GWBS65VC.js → chunk-4B7BL2DD.js} +3 -3
  66. package/dist/{chunk-7FDQGBY7.js → chunk-4OXMTKAL.js} +24 -24
  67. package/dist/chunk-4OXMTKAL.js.map +1 -0
  68. package/dist/{chunk-SSFFCBCF.js → chunk-B6NLZFRW.js} +32 -26
  69. package/dist/chunk-B6NLZFRW.js.map +1 -0
  70. package/dist/{chunk-3EXJP67N.cjs → chunk-BBAUQOW6.cjs} +9 -9
  71. package/dist/{chunk-3EXJP67N.cjs.map → chunk-BBAUQOW6.cjs.map} +1 -1
  72. package/dist/{chunk-NQJE2CC7.cjs → chunk-CMMHCTBX.cjs} +45 -45
  73. package/dist/chunk-CMMHCTBX.cjs.map +1 -0
  74. package/dist/{chunk-O2JIANHK.cjs → chunk-CPPXJATQ.cjs} +51 -45
  75. package/dist/chunk-CPPXJATQ.cjs.map +1 -0
  76. package/dist/{chunk-MDRCAGKZ.js → chunk-DKU775VC.js} +3 -3
  77. package/dist/{chunk-BXFD7JIG.cjs → chunk-GRA7Y2ZG.cjs} +46 -46
  78. package/dist/chunk-GRA7Y2ZG.cjs.map +1 -0
  79. package/dist/{chunk-TE63Y5PX.js → chunk-JJIMABHT.js} +10 -3
  80. package/dist/chunk-JJIMABHT.js.map +1 -0
  81. package/dist/{chunk-KHQPN77E.js → chunk-KJHWXOCZ.js} +4 -4
  82. package/dist/{chunk-6CCZL2JJ.js → chunk-KRWGSOJ2.js} +30 -2
  83. package/dist/chunk-KRWGSOJ2.js.map +1 -0
  84. package/dist/{chunk-VPAEBI2F.js → chunk-LTFH7SEG.js} +24 -24
  85. package/dist/chunk-LTFH7SEG.js.map +1 -0
  86. package/dist/{chunk-E62LCBGD.js → chunk-MUNOKLLE.js} +3 -3
  87. package/dist/{chunk-ZVGEDXDP.cjs → chunk-MXGLYWVP.cjs} +10 -3
  88. package/dist/chunk-MXGLYWVP.cjs.map +1 -0
  89. package/dist/{chunk-55ZCOYDF.cjs → chunk-OYC34VTO.cjs} +252 -827
  90. package/dist/chunk-OYC34VTO.cjs.map +1 -0
  91. package/dist/{chunk-CAFJRZPZ.cjs → chunk-PQL5I3F6.cjs} +17 -17
  92. package/dist/{chunk-CAFJRZPZ.cjs.map → chunk-PQL5I3F6.cjs.map} +1 -1
  93. package/dist/{chunk-UFRBUT2D.cjs → chunk-REZTQ4PH.cjs} +87 -24
  94. package/dist/chunk-REZTQ4PH.cjs.map +1 -0
  95. package/dist/{chunk-RCY6XLGU.cjs → chunk-TZRUHQOH.cjs} +36 -8
  96. package/dist/chunk-TZRUHQOH.cjs.map +1 -0
  97. package/dist/{chunk-XD2HV7M5.js → chunk-UTYVIOWZ.js} +226 -801
  98. package/dist/chunk-UTYVIOWZ.js.map +1 -0
  99. package/dist/{chunk-YAXY5L7I.cjs → chunk-W2LDIX26.cjs} +5 -5
  100. package/dist/{chunk-YAXY5L7I.cjs.map → chunk-W2LDIX26.cjs.map} +1 -1
  101. package/dist/{chunk-K6TGILHX.cjs → chunk-WSG64BVN.cjs} +6 -6
  102. package/dist/{chunk-K6TGILHX.cjs.map → chunk-WSG64BVN.cjs.map} +1 -1
  103. package/dist/chunk-WTQYGUNF.js +400 -0
  104. package/dist/chunk-WTQYGUNF.js.map +1 -0
  105. package/dist/{chunk-YDVHT7GS.cjs → chunk-WYK7EL5R.cjs} +100 -447
  106. package/dist/chunk-WYK7EL5R.cjs.map +1 -0
  107. package/dist/{chunk-X7JL2NYJ.cjs → chunk-XC4KHJYX.cjs} +51 -45
  108. package/dist/chunk-XC4KHJYX.cjs.map +1 -0
  109. package/dist/chunk-XVBH5XHE.cjs +400 -0
  110. package/dist/chunk-XVBH5XHE.cjs.map +1 -0
  111. package/dist/{chunk-IS5YYLTV.js → chunk-YAFQVVDI.js} +85 -22
  112. package/dist/chunk-YAFQVVDI.js.map +1 -0
  113. package/dist/components/legend/index.cjs +4 -3
  114. package/dist/components/legend/index.cjs.map +1 -1
  115. package/dist/components/legend/index.css +12 -24
  116. package/dist/components/legend/index.css.map +1 -1
  117. package/dist/components/legend/index.d.cts +4 -4
  118. package/dist/components/legend/index.d.ts +4 -4
  119. package/dist/components/legend/index.js +3 -2
  120. package/dist/components/tooltip/index.d.cts +1 -1
  121. package/dist/components/tooltip/index.d.ts +1 -1
  122. package/dist/hooks/index.cjs +3 -5
  123. package/dist/hooks/index.cjs.map +1 -1
  124. package/dist/hooks/index.css +0 -94
  125. package/dist/hooks/index.css.map +1 -1
  126. package/dist/hooks/index.d.cts +9 -13
  127. package/dist/hooks/index.d.ts +9 -13
  128. package/dist/hooks/index.js +2 -4
  129. package/dist/index.cjs +18 -17
  130. package/dist/index.cjs.map +1 -1
  131. package/dist/index.css +20 -33
  132. package/dist/index.css.map +1 -1
  133. package/dist/index.d.cts +6 -6
  134. package/dist/index.d.ts +6 -6
  135. package/dist/index.js +17 -16
  136. package/dist/{leaderboard-chart-COtgamhe.d.cts → leaderboard-chart-BSbg0ufV.d.cts} +3 -11
  137. package/dist/{leaderboard-chart-BSgEw_Um.d.ts → leaderboard-chart-odEYxxEC.d.ts} +3 -11
  138. package/dist/{legend-C9ahiwOt.d.cts → legend-DFkosEvC.d.cts} +1 -1
  139. package/dist/{legend-jjMmhSg3.d.ts → legend-DLswHhOk.d.ts} +1 -1
  140. package/dist/providers/index.cjs +3 -3
  141. package/dist/providers/index.css +0 -94
  142. package/dist/providers/index.css.map +1 -1
  143. package/dist/providers/index.d.cts +3 -3
  144. package/dist/providers/index.d.ts +3 -3
  145. package/dist/providers/index.js +2 -2
  146. package/dist/{themes-CVR5rmIs.d.cts → themes-D0qc5JaW.d.cts} +2 -2
  147. package/dist/{themes-DQzmaSze.d.ts → themes-itO4Ht5g.d.ts} +2 -2
  148. package/dist/{types-BBwg4Evw.d.cts → types-B5f6XQ7Q.d.cts} +1 -1
  149. package/dist/{types-DQNnq5Fr.d.ts → types-BsHooDbM.d.ts} +1 -1
  150. package/dist/{types-C05PdDJa.d.cts → types-BuSrRM4p.d.ts} +15 -23
  151. package/dist/{types-CzdN7rUe.d.cts → types-ChOUI9-N.d.cts} +90 -46
  152. package/dist/{types-CzdN7rUe.d.ts → types-ChOUI9-N.d.ts} +90 -46
  153. package/dist/{types-C05PdDJa.d.ts → types-Dfw9VOKI.d.cts} +15 -23
  154. package/dist/utils/index.cjs +2 -2
  155. package/dist/utils/index.d.cts +1 -1
  156. package/dist/utils/index.d.ts +1 -1
  157. package/dist/utils/index.js +1 -1
  158. package/package.json +10 -8
  159. package/src/charts/bar-chart/bar-chart.tsx +19 -19
  160. package/src/charts/bar-chart/test/bar-chart.test.tsx +78 -31
  161. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +2 -2
  162. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +0 -2
  163. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +9 -10
  164. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +124 -102
  165. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +61 -33
  166. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -5
  167. package/src/charts/leaderboard-chart/types.ts +2 -15
  168. package/src/charts/line-chart/line-chart.tsx +18 -17
  169. package/src/charts/line-chart/test/line-chart.test.tsx +49 -27
  170. package/src/charts/line-chart/types.ts +0 -1
  171. package/src/charts/pie-chart/pie-chart.tsx +23 -23
  172. package/src/charts/pie-chart/test/composition-api.test.tsx +41 -0
  173. package/src/charts/pie-chart/test/pie-chart.test.tsx +9 -9
  174. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +21 -24
  175. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +33 -5
  176. package/src/charts/private/chart-composition/index.ts +2 -0
  177. package/src/charts/private/chart-composition/render-legend-slot.ts +22 -0
  178. package/src/charts/private/chart-composition/test/render-legend-slot.test.tsx +60 -0
  179. package/src/charts/private/chart-composition/test/use-chart-children.test.tsx +91 -0
  180. package/src/charts/private/chart-composition/use-chart-children.ts +34 -2
  181. package/src/components/legend/private/base-legend.module.scss +19 -37
  182. package/src/components/legend/private/base-legend.tsx +32 -24
  183. package/src/components/legend/test/legend.test.tsx +148 -52
  184. package/src/components/legend/types.ts +23 -24
  185. package/src/hooks/index.ts +0 -1
  186. package/src/hooks/test/use-zero-value-display.test.tsx +206 -0
  187. package/src/hooks/use-zero-value-display.ts +52 -23
  188. package/src/providers/chart-context/test/chart-context.test.tsx +12 -6
  189. package/src/providers/chart-context/themes.ts +6 -4
  190. package/src/types.ts +93 -44
  191. package/src/utils/date-parsing.ts +10 -1
  192. package/src/utils/get-styles.ts +1 -1
  193. package/src/utils/test/date-parsing.test.ts +12 -0
  194. package/src/utils/test/get-styles.test.ts +12 -10
  195. package/src/utils/test/resolve-css-var.test.ts +2 -2
  196. package/tsup.config.ts +1 -1
  197. package/dist/chunk-55ZCOYDF.cjs.map +0 -1
  198. package/dist/chunk-6CCZL2JJ.js.map +0 -1
  199. package/dist/chunk-7FDQGBY7.js.map +0 -1
  200. package/dist/chunk-BXFD7JIG.cjs.map +0 -1
  201. package/dist/chunk-IS5YYLTV.js.map +0 -1
  202. package/dist/chunk-KNIMXN6Z.js +0 -51
  203. package/dist/chunk-KNIMXN6Z.js.map +0 -1
  204. package/dist/chunk-NQJE2CC7.cjs.map +0 -1
  205. package/dist/chunk-O2JIANHK.cjs.map +0 -1
  206. package/dist/chunk-OMS5QIJN.js.map +0 -1
  207. package/dist/chunk-RCY6XLGU.cjs.map +0 -1
  208. package/dist/chunk-RFSHE3HL.js.map +0 -1
  209. package/dist/chunk-SSFFCBCF.js.map +0 -1
  210. package/dist/chunk-SUDERBUA.cjs +0 -51
  211. package/dist/chunk-SUDERBUA.cjs.map +0 -1
  212. package/dist/chunk-TE63Y5PX.js.map +0 -1
  213. package/dist/chunk-UFRBUT2D.cjs.map +0 -1
  214. package/dist/chunk-VPAEBI2F.js.map +0 -1
  215. package/dist/chunk-X7JL2NYJ.cjs.map +0 -1
  216. package/dist/chunk-XD2HV7M5.js.map +0 -1
  217. package/dist/chunk-YDVHT7GS.cjs.map +0 -1
  218. package/dist/chunk-ZVGEDXDP.cjs.map +0 -1
  219. package/src/hooks/use-has-legend-child.ts +0 -22
  220. /package/dist/{chunk-GWBS65VC.js.map → chunk-4B7BL2DD.js.map} +0 -0
  221. /package/dist/{chunk-MDRCAGKZ.js.map → chunk-DKU775VC.js.map} +0 -0
  222. /package/dist/{chunk-KHQPN77E.js.map → chunk-KJHWXOCZ.js.map} +0 -0
  223. /package/dist/{chunk-E62LCBGD.js.map → chunk-MUNOKLLE.js.map} +0 -0
@@ -1,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,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,32 +67,37 @@ export const BaseLegend: ForwardRefExoticComponent<
67
67
  items,
68
68
  className,
69
69
  orientation = 'horizontal',
70
- position = 'bottom',
71
70
  alignment = 'center',
72
- maxWidth,
73
- textOverflow = 'wrap',
74
71
  shape = 'rect',
75
72
  fill = valueOrIdentityString,
76
73
  size = valueOrIdentityString,
77
74
  labelFormat = valueOrIdentity,
78
75
  labelTransform = labelTransformFactory,
79
- shapeWidth = 16,
80
- shapeHeight = 16,
81
- shapeMargin = '2px 4px 2px 0',
82
- labelAlign = 'left',
83
- labelFlex = '0 0 auto', // Use natural width instead of expanding to fill space
84
- labelMargin = '0 4px',
85
- itemMargin = '0',
86
- itemDirection = 'row',
87
- legendLabelProps,
88
- legendItemClassName,
76
+ itemStyles,
77
+ itemClassName,
78
+ labelStyles,
79
+ labelClassName,
80
+ shapeStyles,
89
81
  render,
90
82
  interactive = false,
91
83
  chartId,
92
- ...legendItemProps
93
84
  },
94
85
  ref
95
86
  ) => {
87
+ const { margin: itemMargin = '0', flexDirection: itemDirection = 'row' } = itemStyles ?? {};
88
+ const {
89
+ justifyContent: labelJustifyContent = 'flex-start',
90
+ flex: labelFlex = '0 0 auto',
91
+ margin: labelMargin = '0 4px',
92
+ maxWidth,
93
+ textOverflow = 'wrap',
94
+ } = labelStyles ?? {};
95
+ const {
96
+ width: shapeWidth = 16,
97
+ height: shapeHeight = 16,
98
+ margin: shapeMargin = '2px 4px 2px 0',
99
+ } = shapeStyles ?? {};
100
+
96
101
  const theme = useGlobalChartsTheme();
97
102
  const context = useContext( GlobalChartsContext );
98
103
 
@@ -171,18 +176,18 @@ export const BaseLegend: ForwardRefExoticComponent<
171
176
  styles.legend,
172
177
  styles[ `legend--${ orientation }` ],
173
178
  styles[ `legend--alignment-${ alignment }` ],
174
- styles[ `legend--position-${ position }` ],
175
179
  className
176
180
  ) }
177
181
  style={ {
178
182
  flexDirection: orientationToFlexDirection[ orientation ],
179
- ...theme.legendContainerStyles,
183
+ ...theme.legend?.containerStyles,
180
184
  } }
181
185
  >
182
186
  { labels.map( ( label, i ) => {
183
187
  const visible = isSeriesVisible( label.text );
184
188
  const handleClick = createClickHandler( label.text );
185
189
  const handleKeyDown = createKeyDownHandler( label.text );
190
+ const matchedItem = items[ i ];
186
191
 
187
192
  return (
188
193
  <LegendItem
@@ -191,7 +196,7 @@ export const BaseLegend: ForwardRefExoticComponent<
191
196
  styles[ 'legend-item' ],
192
197
  interactive && styles[ 'legend-item--interactive' ],
193
198
  ! visible && styles[ 'legend-item--inactive' ],
194
- legendItemClassName
199
+ itemClassName
195
200
  ) }
196
201
  data-testid="legend-item"
197
202
  key={ `legend-${ label.text }-${ i }` }
@@ -211,7 +216,6 @@ export const BaseLegend: ForwardRefExoticComponent<
211
216
  ? `${ label.text }: ${ visible ? 'visible' : 'hidden' }. Toggle visibility.`
212
217
  : undefined
213
218
  }
214
- { ...legendItemProps }
215
219
  >
216
220
  { items[ i ]?.renderGlyph ? (
217
221
  <svg
@@ -246,24 +250,28 @@ export const BaseLegend: ForwardRefExoticComponent<
246
250
  />
247
251
  ) }
248
252
  <LegendLabel
249
- className={ clsx( 'visx-legend-label', styles[ 'legend-item-label' ] ) }
253
+ data-testid="legend-label"
254
+ className={ clsx(
255
+ 'visx-legend-label',
256
+ styles[ 'legend-item-label' ],
257
+ labelClassName
258
+ ) }
250
259
  style={ {
251
- justifyContent: labelAlign,
260
+ justifyContent: labelJustifyContent,
252
261
  flex: labelFlex,
253
262
  margin: labelMargin,
254
- ...theme.legendLabelStyles,
263
+ ...theme.legend?.labelStyles,
255
264
  } }
256
- { ...legendLabelProps }
257
265
  >
258
266
  <LegendText
259
267
  text={ label.text }
260
268
  textOverflow={ textOverflow }
261
269
  maxWidth={ maxWidth }
262
270
  />
263
- { items.find( item => item.label === label.text )?.value && (
271
+ { matchedItem?.value != null && matchedItem.value !== '' && (
264
272
  <span className={ styles[ 'legend-item-value' ] }>
265
273
  { '\u00A0' }
266
- { items.find( item => item.label === label.text )?.value }
274
+ { matchedItem.value }
267
275
  </span>
268
276
  ) }
269
277
  </LegendLabel>
@@ -46,12 +46,12 @@ describe( 'BaseLegend', () => {
46
46
  expect( legendItems ).toHaveLength( 0 );
47
47
  } );
48
48
 
49
- test( 'applies legendItemClassName to legend items', () => {
49
+ test( 'applies itemClassName to legend items', () => {
50
50
  render(
51
51
  <BaseLegend
52
52
  items={ defaultItems }
53
53
  orientation="horizontal"
54
- legendItemClassName="custom-legend-item"
54
+ itemClassName="custom-legend-item"
55
55
  />
56
56
  );
57
57
  const legendItems = screen.getAllByTestId( 'legend-item' );
@@ -60,16 +60,65 @@ describe( 'BaseLegend', () => {
60
60
  } );
61
61
  } );
62
62
 
63
+ test( 'applies labelClassName to legend labels', () => {
64
+ render(
65
+ <BaseLegend
66
+ items={ defaultItems }
67
+ orientation="horizontal"
68
+ labelClassName="custom-legend-label"
69
+ />
70
+ );
71
+ const labels = screen.getAllByTestId( 'legend-label' );
72
+ labels.forEach( label => {
73
+ expect( label ).toHaveClass( 'custom-legend-label' );
74
+ } );
75
+ } );
76
+
63
77
  test( 'handles missing values', () => {
64
78
  const itemsWithoutValues = [
65
- { label: 'Item 1', color: '#ff0000', value: undefined },
66
- { label: 'Item 2', color: '#00ff00', value: undefined },
79
+ { label: 'Item 1', color: '#ff0000' },
80
+ { label: 'Item 2', color: '#00ff00' },
67
81
  ];
68
82
  render( <BaseLegend items={ itemsWithoutValues } orientation="horizontal" /> );
69
83
  expect( screen.getByText( 'Item 1' ) ).toBeInTheDocument();
70
84
  expect( screen.getByText( 'Item 2' ) ).toBeInTheDocument();
71
85
  } );
72
86
 
87
+ test( 'does not render value span for empty string values', () => {
88
+ const itemsWithEmptyValues = [
89
+ { label: 'Item 1', color: '#ff0000', value: '' },
90
+ { label: 'Item 2', color: '#00ff00', value: '' },
91
+ ];
92
+ render( <BaseLegend items={ itemsWithEmptyValues } orientation="horizontal" /> );
93
+ expect( screen.getByText( 'Item 1' ) ).toBeInTheDocument();
94
+ expect( screen.getByText( 'Item 2' ) ).toBeInTheDocument();
95
+ expect( screen.queryByText( '\u00A0' ) ).not.toBeInTheDocument();
96
+ } );
97
+
98
+ test( 'renders numeric value of 0 without hiding it', () => {
99
+ const itemsWithZeroValue = [
100
+ { label: 'Item 1', color: '#ff0000', value: 0 },
101
+ { label: 'Item 2', color: '#00ff00', value: '0%' },
102
+ ];
103
+ render( <BaseLegend items={ itemsWithZeroValue } orientation="horizontal" /> );
104
+ expect( screen.getByText( '0' ) ).toBeInTheDocument();
105
+ expect( screen.getByText( '0%' ) ).toBeInTheDocument();
106
+ } );
107
+
108
+ test( 'renders each value next to its corresponding label by index', () => {
109
+ const itemsWithDistinctValues = [
110
+ { label: 'Alpha', value: '100', color: '#ff0000' },
111
+ { label: 'Beta', value: '200', color: '#00ff00' },
112
+ { label: 'Gamma', value: '300', color: '#0000ff' },
113
+ ];
114
+ render( <BaseLegend items={ itemsWithDistinctValues } orientation="horizontal" /> );
115
+ const legendItems = screen.getAllByTestId( 'legend-item' );
116
+ expect( legendItems ).toHaveLength( 3 );
117
+ expect( legendItems[ 0 ] ).toHaveTextContent( /Alpha.*100/ );
118
+ expect( legendItems[ 1 ] ).toHaveTextContent( /Beta.*200/ );
119
+ expect( legendItems[ 2 ] ).toHaveTextContent( /Gamma.*300/ );
120
+ } );
121
+
73
122
  test( 'applies custom className', () => {
74
123
  render(
75
124
  <BaseLegend items={ defaultItems } className="custom-legend" orientation="horizontal" />
@@ -106,108 +155,155 @@ describe( 'BaseLegend', () => {
106
155
  { label: 'Another Long Label for Testing', value: '30%', color: '#00ff00' },
107
156
  ];
108
157
 
109
- test( 'renders with maxWidth constraint', () => {
110
- render( <BaseLegend items={ longLabelItems } maxWidth="150px" orientation="horizontal" /> );
111
- const legendItems = screen.getAllByTestId( 'legend-item' );
112
- expect( legendItems ).toHaveLength( 2 );
113
- // Note: maxWidth is applied to LegendLabel via inline styles,
114
- // which is harder to test without DOM traversal
158
+ test( 'applies maxWidth and minWidth styles to label text', () => {
159
+ render(
160
+ <BaseLegend
161
+ items={ longLabelItems }
162
+ labelStyles={ { maxWidth: '150px' } }
163
+ orientation="horizontal"
164
+ />
165
+ );
166
+ const labels = screen.getAllByText( /Long Label/ );
167
+ labels.forEach( label => {
168
+ expect( label ).toHaveStyle( { maxWidth: '150px', minWidth: 0 } );
169
+ } );
115
170
  } );
116
171
 
117
- test( 'renders with maxWidth as string', () => {
118
- render( <BaseLegend items={ longLabelItems } maxWidth="10rem" orientation="horizontal" /> );
119
- const legendItems = screen.getAllByTestId( 'legend-item' );
120
- expect( legendItems ).toHaveLength( 2 );
121
- // Note: maxWidth is applied to LegendLabel via inline styles
172
+ test( 'supports different CSS units for maxWidth', () => {
173
+ render(
174
+ <BaseLegend
175
+ items={ longLabelItems }
176
+ labelStyles={ { maxWidth: '10rem' } }
177
+ orientation="horizontal"
178
+ />
179
+ );
180
+ const labels = screen.getAllByText( /Long Label/ );
181
+ labels.forEach( label => {
182
+ expect( label ).toHaveStyle( { maxWidth: '10rem' } );
183
+ } );
122
184
  } );
123
185
 
124
- test( 'renders correctly with and without maxWidth', () => {
186
+ test( 'does not apply maxWidth styles when omitted', () => {
125
187
  const { rerender } = render(
126
188
  <BaseLegend items={ longLabelItems } orientation="horizontal" />
127
189
  );
128
190
 
129
- // Without maxWidth, legend items should render normally
130
- let legendItems = screen.getAllByTestId( 'legend-item' );
131
- expect( legendItems ).toHaveLength( 2 );
191
+ let labels = screen.getAllByText( /Long Label/ );
192
+ labels.forEach( label => {
193
+ expect( label ).not.toHaveStyle( { maxWidth: '150px' } );
194
+ } );
132
195
 
133
- // With maxWidth, legend items should still render
134
- rerender( <BaseLegend items={ longLabelItems } maxWidth="150px" orientation="horizontal" /> );
135
- legendItems = screen.getAllByTestId( 'legend-item' );
136
- expect( legendItems ).toHaveLength( 2 );
196
+ rerender(
197
+ <BaseLegend
198
+ items={ longLabelItems }
199
+ labelStyles={ { maxWidth: '150px' } }
200
+ orientation="horizontal"
201
+ />
202
+ );
203
+ labels = screen.getAllByText( /Long Label/ );
204
+ labels.forEach( label => {
205
+ expect( label ).toHaveStyle( { maxWidth: '150px', minWidth: 0 } );
206
+ } );
137
207
  } );
138
208
 
139
- test( 'renders with different textOverflow values', () => {
140
- // Test ellipsis behavior - should render without errors
209
+ test( 'applies maxWidth styles for both textOverflow modes', () => {
141
210
  const { rerender } = render(
142
211
  <BaseLegend
143
212
  items={ longLabelItems }
144
- maxWidth="150px"
145
- textOverflow="ellipsis"
213
+ labelStyles={ { maxWidth: '150px', textOverflow: 'ellipsis' } }
146
214
  orientation="horizontal"
147
215
  />
148
216
  );
149
217
  let labels = screen.getAllByText( /Long Label/ );
150
- expect( labels ).toHaveLength( 2 );
218
+ labels.forEach( label => {
219
+ expect( label ).toHaveStyle( { maxWidth: '150px', minWidth: 0 } );
220
+ } );
151
221
 
152
- // Test wrap behavior - should render without errors
153
222
  rerender(
154
223
  <BaseLegend
155
224
  items={ longLabelItems }
156
- maxWidth="150px"
157
- textOverflow="wrap"
225
+ labelStyles={ { maxWidth: '150px', textOverflow: 'wrap' } }
158
226
  orientation="horizontal"
159
227
  />
160
228
  );
161
229
  labels = screen.getAllByText( /Long Label/ );
162
- expect( labels ).toHaveLength( 2 );
230
+ labels.forEach( label => {
231
+ expect( label ).toHaveStyle( { maxWidth: '150px', minWidth: 0 } );
232
+ } );
163
233
  } );
164
234
 
165
- test( 'renders ellipsis mode without errors', () => {
166
- // Render with ellipsis overflow
235
+ test( 'applies maxWidth=0px correctly', () => {
167
236
  render(
168
237
  <BaseLegend
169
238
  items={ longLabelItems }
170
- maxWidth="50px"
171
- textOverflow="ellipsis"
239
+ labelStyles={ { maxWidth: '0px', textOverflow: 'ellipsis' } }
172
240
  orientation="horizontal"
173
241
  />
174
242
  );
175
-
176
- // Verify the text is rendered
177
243
  const labels = screen.getAllByText( /Long Label/ );
178
- expect( labels ).toHaveLength( 2 );
244
+ labels.forEach( label => {
245
+ expect( label ).toHaveStyle( { maxWidth: '0px', minWidth: 0 } );
246
+ } );
179
247
  } );
180
248
 
181
- test( 'renders wrap mode without errors', () => {
182
- // Render with wrap overflow
249
+ test( 'applies legend-item-text--ellipsis class when textOverflow is ellipsis and maxWidth is set', () => {
183
250
  render(
184
251
  <BaseLegend
185
252
  items={ longLabelItems }
186
- maxWidth="50px"
187
- textOverflow="wrap"
253
+ labelStyles={ { maxWidth: '150px', textOverflow: 'ellipsis' } }
188
254
  orientation="horizontal"
189
255
  />
190
256
  );
257
+ const labels = screen.getAllByText( /Long Label/ );
258
+ labels.forEach( label => {
259
+ expect( label ).toHaveClass( 'legend-item-text--ellipsis' );
260
+ expect( label ).not.toHaveClass( 'legend-item-text--wrap' );
261
+ } );
262
+ } );
191
263
 
192
- // Verify the text is rendered
264
+ test( 'applies legend-item-text--wrap class when textOverflow is wrap and maxWidth is set', () => {
265
+ render(
266
+ <BaseLegend
267
+ items={ longLabelItems }
268
+ labelStyles={ { maxWidth: '150px', textOverflow: 'wrap' } }
269
+ orientation="horizontal"
270
+ />
271
+ );
193
272
  const labels = screen.getAllByText( /Long Label/ );
194
- expect( labels ).toHaveLength( 2 );
273
+ labels.forEach( label => {
274
+ expect( label ).toHaveClass( 'legend-item-text--wrap' );
275
+ expect( label ).not.toHaveClass( 'legend-item-text--ellipsis' );
276
+ } );
195
277
  } );
196
278
 
197
- test( 'handles maxWidth={0} correctly', () => {
198
- // maxWidth={0} should apply 0-pixel constraint (not be treated as "no constraint")
279
+ test( 'does not apply overflow class when maxWidth is not set', () => {
199
280
  render(
200
281
  <BaseLegend
201
282
  items={ longLabelItems }
202
- maxWidth="0px"
203
- textOverflow="ellipsis"
283
+ labelStyles={ { textOverflow: 'ellipsis' } }
204
284
  orientation="horizontal"
205
285
  />
206
286
  );
287
+ const labels = screen.getAllByText( /Long Label/ );
288
+ labels.forEach( label => {
289
+ expect( label ).not.toHaveClass( 'legend-item-text--ellipsis' );
290
+ expect( label ).not.toHaveClass( 'legend-item-text--wrap' );
291
+ } );
292
+ } );
207
293
 
208
- // Should still render the legend items
209
- const legendItems = screen.getAllByTestId( 'legend-item' );
210
- expect( legendItems ).toHaveLength( 2 );
294
+ test( 'does not apply overflow class when maxWidth is not set and textOverflow is wrap', () => {
295
+ render(
296
+ <BaseLegend
297
+ items={ longLabelItems }
298
+ labelStyles={ { textOverflow: 'wrap' } }
299
+ orientation="horizontal"
300
+ />
301
+ );
302
+ const labels = screen.getAllByText( /Long Label/ );
303
+ labels.forEach( label => {
304
+ expect( label ).not.toHaveClass( 'legend-item-text--wrap' );
305
+ expect( label ).not.toHaveClass( 'legend-item-text--ellipsis' );
306
+ } );
211
307
  } );
212
308
  } );
213
309