@automattic/charts 0.56.7 → 0.57.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 (176) hide show
  1. package/AGENTS.md +28 -98
  2. package/CHANGELOG.md +16 -0
  3. package/dist/charts/bar-chart/index.cjs +5 -6
  4. package/dist/charts/bar-chart/index.cjs.map +1 -1
  5. package/dist/charts/bar-chart/index.d.cts +3 -3
  6. package/dist/charts/bar-chart/index.d.ts +3 -3
  7. package/dist/charts/bar-chart/index.js +4 -5
  8. package/dist/charts/bar-list-chart/index.cjs +6 -7
  9. package/dist/charts/bar-list-chart/index.cjs.map +1 -1
  10. package/dist/charts/bar-list-chart/index.d.cts +3 -3
  11. package/dist/charts/bar-list-chart/index.d.ts +3 -3
  12. package/dist/charts/bar-list-chart/index.js +5 -6
  13. package/dist/charts/conversion-funnel-chart/index.cjs +5 -6
  14. package/dist/charts/conversion-funnel-chart/index.cjs.map +1 -1
  15. package/dist/charts/conversion-funnel-chart/index.d.cts +1 -1
  16. package/dist/charts/conversion-funnel-chart/index.d.ts +1 -1
  17. package/dist/charts/conversion-funnel-chart/index.js +4 -5
  18. package/dist/charts/geo-chart/index.cjs +4 -4
  19. package/dist/charts/geo-chart/index.d.cts +1 -1
  20. package/dist/charts/geo-chart/index.d.ts +1 -1
  21. package/dist/charts/geo-chart/index.js +3 -3
  22. package/dist/charts/leaderboard-chart/index.cjs +5 -5
  23. package/dist/charts/leaderboard-chart/index.css +8 -9
  24. package/dist/charts/leaderboard-chart/index.css.map +1 -1
  25. package/dist/charts/leaderboard-chart/index.d.cts +3 -3
  26. package/dist/charts/leaderboard-chart/index.d.ts +3 -3
  27. package/dist/charts/leaderboard-chart/index.js +4 -4
  28. package/dist/charts/line-chart/index.cjs +5 -6
  29. package/dist/charts/line-chart/index.cjs.map +1 -1
  30. package/dist/charts/line-chart/index.d.cts +3 -3
  31. package/dist/charts/line-chart/index.d.ts +3 -3
  32. package/dist/charts/line-chart/index.js +4 -5
  33. package/dist/charts/pie-chart/index.cjs +5 -6
  34. package/dist/charts/pie-chart/index.cjs.map +1 -1
  35. package/dist/charts/pie-chart/index.d.cts +4 -4
  36. package/dist/charts/pie-chart/index.d.ts +4 -4
  37. package/dist/charts/pie-chart/index.js +4 -5
  38. package/dist/charts/pie-semi-circle-chart/index.cjs +5 -6
  39. package/dist/charts/pie-semi-circle-chart/index.cjs.map +1 -1
  40. package/dist/charts/pie-semi-circle-chart/index.d.cts +4 -4
  41. package/dist/charts/pie-semi-circle-chart/index.d.ts +4 -4
  42. package/dist/charts/pie-semi-circle-chart/index.js +4 -5
  43. package/dist/charts/sparkline/index.cjs +6 -7
  44. package/dist/charts/sparkline/index.cjs.map +1 -1
  45. package/dist/charts/sparkline/index.js +5 -6
  46. package/dist/{chunk-XD2HV7M5.js → chunk-2NCY7R4G.js} +127 -762
  47. package/dist/chunk-2NCY7R4G.js.map +1 -0
  48. package/dist/{chunk-RFSHE3HL.js → chunk-32DH6JDF.js} +64 -43
  49. package/dist/chunk-32DH6JDF.js.map +1 -0
  50. package/dist/{chunk-SSFFCBCF.js → chunk-4OPFE4RM.js} +11 -8
  51. package/dist/chunk-4OPFE4RM.js.map +1 -0
  52. package/dist/{chunk-CAFJRZPZ.cjs → chunk-77OKCVQN.cjs} +17 -17
  53. package/dist/{chunk-CAFJRZPZ.cjs.map → chunk-77OKCVQN.cjs.map} +1 -1
  54. package/dist/{chunk-K6TGILHX.cjs → chunk-7FQX4ALL.cjs} +6 -6
  55. package/dist/{chunk-K6TGILHX.cjs.map → chunk-7FQX4ALL.cjs.map} +1 -1
  56. package/dist/{chunk-7FDQGBY7.js → chunk-BCX5THDQ.js} +9 -7
  57. package/dist/chunk-BCX5THDQ.js.map +1 -0
  58. package/dist/{chunk-KHQPN77E.js → chunk-CZGYJKG6.js} +4 -4
  59. package/dist/{chunk-3EXJP67N.cjs → chunk-D2UH4CFE.cjs} +9 -9
  60. package/dist/{chunk-3EXJP67N.cjs.map → chunk-D2UH4CFE.cjs.map} +1 -1
  61. package/dist/{chunk-TE63Y5PX.js → chunk-DAU3HNEG.js} +2 -2
  62. package/dist/chunk-DAU3HNEG.js.map +1 -0
  63. package/dist/{chunk-MDRCAGKZ.js → chunk-H2V4JMSA.js} +3 -3
  64. package/dist/{chunk-UFRBUT2D.cjs → chunk-I35UYJJR.cjs} +49 -6
  65. package/dist/chunk-I35UYJJR.cjs.map +1 -0
  66. package/dist/{chunk-GWBS65VC.js → chunk-IU4DYUAV.js} +3 -3
  67. package/dist/{chunk-E62LCBGD.js → chunk-PXLEMUGJ.js} +3 -3
  68. package/dist/{chunk-YDVHT7GS.cjs → chunk-RHHVEJHJ.cjs} +83 -62
  69. package/dist/chunk-RHHVEJHJ.cjs.map +1 -0
  70. package/dist/{chunk-YAXY5L7I.cjs → chunk-TO3OQBXG.cjs} +5 -5
  71. package/dist/{chunk-YAXY5L7I.cjs.map → chunk-TO3OQBXG.cjs.map} +1 -1
  72. package/dist/{chunk-VPAEBI2F.js → chunk-V36ERY7Y.js} +9 -7
  73. package/dist/chunk-V36ERY7Y.js.map +1 -0
  74. package/dist/{chunk-X7JL2NYJ.cjs → chunk-VJM5XCB4.cjs} +33 -30
  75. package/dist/chunk-VJM5XCB4.cjs.map +1 -0
  76. package/dist/{chunk-ZVGEDXDP.cjs → chunk-VTS3PNMS.cjs} +2 -2
  77. package/dist/{chunk-ZVGEDXDP.cjs.map → chunk-VTS3PNMS.cjs.map} +1 -1
  78. package/dist/{chunk-OMS5QIJN.js → chunk-WLODYNLB.js} +9 -7
  79. package/dist/chunk-WLODYNLB.js.map +1 -0
  80. package/dist/{chunk-NQJE2CC7.cjs → chunk-XKRJL2QT.cjs} +25 -23
  81. package/dist/chunk-XKRJL2QT.cjs.map +1 -0
  82. package/dist/{chunk-O2JIANHK.cjs → chunk-YE2T52VZ.cjs} +33 -31
  83. package/dist/chunk-YE2T52VZ.cjs.map +1 -0
  84. package/dist/{chunk-IS5YYLTV.js → chunk-Z26M4V2M.js} +46 -3
  85. package/dist/chunk-Z26M4V2M.js.map +1 -0
  86. package/dist/{chunk-55ZCOYDF.cjs → chunk-Z45KX47P.cjs} +153 -788
  87. package/dist/chunk-Z45KX47P.cjs.map +1 -0
  88. package/dist/{chunk-BXFD7JIG.cjs → chunk-ZH4F5RMG.cjs} +26 -24
  89. package/dist/chunk-ZH4F5RMG.cjs.map +1 -0
  90. package/dist/components/legend/index.cjs +3 -3
  91. package/dist/components/legend/index.d.cts +4 -4
  92. package/dist/components/legend/index.d.ts +4 -4
  93. package/dist/components/legend/index.js +2 -2
  94. package/dist/components/tooltip/index.d.cts +1 -1
  95. package/dist/components/tooltip/index.d.ts +1 -1
  96. package/dist/hooks/index.cjs +3 -3
  97. package/dist/hooks/index.d.cts +7 -3
  98. package/dist/hooks/index.d.ts +7 -3
  99. package/dist/hooks/index.js +2 -2
  100. package/dist/index.cjs +13 -14
  101. package/dist/index.cjs.map +1 -1
  102. package/dist/index.css +8 -9
  103. package/dist/index.css.map +1 -1
  104. package/dist/index.d.cts +7 -7
  105. package/dist/index.d.ts +7 -7
  106. package/dist/index.js +12 -13
  107. package/dist/{leaderboard-chart-BSgEw_Um.d.ts → leaderboard-chart-BKYYXcg2.d.ts} +5 -9
  108. package/dist/{leaderboard-chart-COtgamhe.d.cts → leaderboard-chart-DR7CGb0L.d.cts} +5 -9
  109. package/dist/{legend-C9ahiwOt.d.cts → legend-C2grwnWk.d.cts} +1 -1
  110. package/dist/{legend-jjMmhSg3.d.ts → legend-Cj0xM5dU.d.ts} +1 -1
  111. package/dist/providers/index.cjs +3 -3
  112. package/dist/providers/index.d.cts +3 -3
  113. package/dist/providers/index.d.ts +3 -3
  114. package/dist/providers/index.js +2 -2
  115. package/dist/{themes-DQzmaSze.d.ts → themes-BmVGrYnF.d.ts} +2 -2
  116. package/dist/{themes-CVR5rmIs.d.cts → themes-CyjKm-P_.d.cts} +2 -2
  117. package/dist/{types-DQNnq5Fr.d.ts → types-CuUEszrM.d.ts} +1 -1
  118. package/dist/{types-CzdN7rUe.d.cts → types-DZordNiO.d.cts} +11 -7
  119. package/dist/{types-CzdN7rUe.d.ts → types-DZordNiO.d.ts} +11 -7
  120. package/dist/types-I67mddpr.d.cts +78 -0
  121. package/dist/types-I67mddpr.d.ts +78 -0
  122. package/dist/{types-BBwg4Evw.d.cts → types-KtOPPzPX.d.cts} +1 -1
  123. package/dist/utils/index.cjs +2 -2
  124. package/dist/utils/index.d.cts +1 -1
  125. package/dist/utils/index.d.ts +1 -1
  126. package/dist/utils/index.js +1 -1
  127. package/package.json +6 -4
  128. package/src/charts/bar-chart/bar-chart.tsx +4 -3
  129. package/src/charts/bar-chart/test/bar-chart.test.tsx +30 -0
  130. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +2 -2
  131. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +0 -2
  132. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +9 -10
  133. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +95 -70
  134. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +58 -29
  135. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -5
  136. package/src/charts/leaderboard-chart/types.ts +4 -7
  137. package/src/charts/line-chart/line-chart.tsx +2 -3
  138. package/src/charts/pie-chart/pie-chart.tsx +2 -3
  139. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +2 -3
  140. package/src/components/legend/index.ts +8 -1
  141. package/src/components/legend/private/base-legend.tsx +32 -22
  142. package/src/components/legend/test/legend.test.tsx +148 -52
  143. package/src/components/legend/types.ts +42 -16
  144. package/src/hooks/test/use-zero-value-display.test.tsx +206 -0
  145. package/src/hooks/use-zero-value-display.ts +52 -23
  146. package/src/index.ts +7 -1
  147. package/src/providers/chart-context/test/chart-context.test.tsx +12 -6
  148. package/src/providers/chart-context/themes.ts +6 -4
  149. package/src/types.ts +11 -7
  150. package/src/utils/get-styles.ts +1 -1
  151. package/src/utils/test/get-styles.test.ts +12 -10
  152. package/dist/chunk-55ZCOYDF.cjs.map +0 -1
  153. package/dist/chunk-7FDQGBY7.js.map +0 -1
  154. package/dist/chunk-BXFD7JIG.cjs.map +0 -1
  155. package/dist/chunk-IS5YYLTV.js.map +0 -1
  156. package/dist/chunk-KNIMXN6Z.js +0 -51
  157. package/dist/chunk-KNIMXN6Z.js.map +0 -1
  158. package/dist/chunk-NQJE2CC7.cjs.map +0 -1
  159. package/dist/chunk-O2JIANHK.cjs.map +0 -1
  160. package/dist/chunk-OMS5QIJN.js.map +0 -1
  161. package/dist/chunk-RFSHE3HL.js.map +0 -1
  162. package/dist/chunk-SSFFCBCF.js.map +0 -1
  163. package/dist/chunk-SUDERBUA.cjs +0 -51
  164. package/dist/chunk-SUDERBUA.cjs.map +0 -1
  165. package/dist/chunk-TE63Y5PX.js.map +0 -1
  166. package/dist/chunk-UFRBUT2D.cjs.map +0 -1
  167. package/dist/chunk-VPAEBI2F.js.map +0 -1
  168. package/dist/chunk-X7JL2NYJ.cjs.map +0 -1
  169. package/dist/chunk-XD2HV7M5.js.map +0 -1
  170. package/dist/chunk-YDVHT7GS.cjs.map +0 -1
  171. package/dist/types-C05PdDJa.d.cts +0 -57
  172. package/dist/types-C05PdDJa.d.ts +0 -57
  173. /package/dist/{chunk-KHQPN77E.js.map → chunk-CZGYJKG6.js.map} +0 -0
  174. /package/dist/{chunk-MDRCAGKZ.js.map → chunk-H2V4JMSA.js.map} +0 -0
  175. /package/dist/{chunk-GWBS65VC.js.map → chunk-IU4DYUAV.js.map} +0 -0
  176. /package/dist/{chunk-E62LCBGD.js.map → chunk-PXLEMUGJ.js.map} +0 -0
@@ -69,30 +69,36 @@ export const BaseLegend: ForwardRefExoticComponent<
69
69
  orientation = 'horizontal',
70
70
  position = 'bottom',
71
71
  alignment = 'center',
72
- maxWidth,
73
- textOverflow = 'wrap',
74
72
  shape = 'rect',
75
73
  fill = valueOrIdentityString,
76
74
  size = valueOrIdentityString,
77
75
  labelFormat = valueOrIdentity,
78
76
  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,
77
+ itemStyles,
78
+ itemClassName,
79
+ labelStyles,
80
+ labelClassName,
81
+ shapeStyles,
89
82
  render,
90
83
  interactive = false,
91
84
  chartId,
92
- ...legendItemProps
93
85
  },
94
86
  ref
95
87
  ) => {
88
+ const { margin: itemMargin = '0', flexDirection: itemDirection = 'row' } = itemStyles ?? {};
89
+ const {
90
+ justifyContent: labelJustifyContent = 'flex-start',
91
+ flex: labelFlex = '0 0 auto',
92
+ margin: labelMargin = '0 4px',
93
+ maxWidth,
94
+ textOverflow = 'wrap',
95
+ } = labelStyles ?? {};
96
+ const {
97
+ width: shapeWidth = 16,
98
+ height: shapeHeight = 16,
99
+ margin: shapeMargin = '2px 4px 2px 0',
100
+ } = shapeStyles ?? {};
101
+
96
102
  const theme = useGlobalChartsTheme();
97
103
  const context = useContext( GlobalChartsContext );
98
104
 
@@ -176,13 +182,14 @@ export const BaseLegend: ForwardRefExoticComponent<
176
182
  ) }
177
183
  style={ {
178
184
  flexDirection: orientationToFlexDirection[ orientation ],
179
- ...theme.legendContainerStyles,
185
+ ...theme.legend?.containerStyles,
180
186
  } }
181
187
  >
182
188
  { labels.map( ( label, i ) => {
183
189
  const visible = isSeriesVisible( label.text );
184
190
  const handleClick = createClickHandler( label.text );
185
191
  const handleKeyDown = createKeyDownHandler( label.text );
192
+ const matchedItem = items[ i ];
186
193
 
187
194
  return (
188
195
  <LegendItem
@@ -191,7 +198,7 @@ export const BaseLegend: ForwardRefExoticComponent<
191
198
  styles[ 'legend-item' ],
192
199
  interactive && styles[ 'legend-item--interactive' ],
193
200
  ! visible && styles[ 'legend-item--inactive' ],
194
- legendItemClassName
201
+ itemClassName
195
202
  ) }
196
203
  data-testid="legend-item"
197
204
  key={ `legend-${ label.text }-${ i }` }
@@ -211,7 +218,6 @@ export const BaseLegend: ForwardRefExoticComponent<
211
218
  ? `${ label.text }: ${ visible ? 'visible' : 'hidden' }. Toggle visibility.`
212
219
  : undefined
213
220
  }
214
- { ...legendItemProps }
215
221
  >
216
222
  { items[ i ]?.renderGlyph ? (
217
223
  <svg
@@ -246,24 +252,28 @@ export const BaseLegend: ForwardRefExoticComponent<
246
252
  />
247
253
  ) }
248
254
  <LegendLabel
249
- className={ clsx( 'visx-legend-label', styles[ 'legend-item-label' ] ) }
255
+ data-testid="legend-label"
256
+ className={ clsx(
257
+ 'visx-legend-label',
258
+ styles[ 'legend-item-label' ],
259
+ labelClassName
260
+ ) }
250
261
  style={ {
251
- justifyContent: labelAlign,
262
+ justifyContent: labelJustifyContent,
252
263
  flex: labelFlex,
253
264
  margin: labelMargin,
254
- ...theme.legendLabelStyles,
265
+ ...theme.legend?.labelStyles,
255
266
  } }
256
- { ...legendLabelProps }
257
267
  >
258
268
  <LegendText
259
269
  text={ label.text }
260
270
  textOverflow={ textOverflow }
261
271
  maxWidth={ maxWidth }
262
272
  />
263
- { items.find( item => item.label === label.text )?.value && (
273
+ { matchedItem?.value != null && matchedItem.value !== '' && (
264
274
  <span className={ styles[ 'legend-item-value' ] }>
265
275
  { '\u00A0' }
266
- { items.find( item => item.label === label.text )?.value }
276
+ { matchedItem.value }
267
277
  </span>
268
278
  ) }
269
279
  </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
 
@@ -2,20 +2,22 @@ import { LegendOrdinal } from '@visx/legend';
2
2
  import type { GlyphProps, LineStyles } from '@visx/xychart';
3
3
  import type { ComponentProps, CSSProperties, ReactNode } from 'react';
4
4
 
5
- // See https://airbnb.io/visx/docs/legend#Ordinal for more details.
6
- type LegendOrdinalProps = Omit< ComponentProps< typeof LegendOrdinal >, 'scale' | 'direction' >;
5
+ type VisxLegendProps = Pick<
6
+ ComponentProps< typeof LegendOrdinal >,
7
+ 'className' | 'shape' | 'fill' | 'size' | 'labelFormat' | 'labelTransform'
8
+ >;
7
9
 
8
- export type BaseLegendProps = Omit< LegendOrdinalProps, 'shapeStyle' > & {
9
- items: BaseLegendItem[];
10
- orientation?: 'horizontal' | 'vertical';
11
- /**
12
- * TODO: Add 'left' | 'right' positioning support in future implementation
13
- */
14
- position?: 'top' | 'bottom';
15
- alignment?: 'start' | 'center' | 'end';
10
+ export type LegendItemStyles = {
11
+ /** Margin around each legend item. */
12
+ margin?: CSSProperties[ 'margin' ];
13
+ /** Flex direction for items within each legend entry. */
14
+ flexDirection?: 'row' | 'row-reverse' | 'column' | 'column-reverse';
15
+ };
16
+
17
+ export type LegendLabelStyles = Pick< CSSProperties, 'justifyContent' | 'flex' | 'margin' > & {
16
18
  /**
17
- * Maximum width for legend items. When set, text overflow behavior is controlled by textOverflow prop.
18
- * Should be a CSS value string (e.g. '200px', '50%', '10rem')
19
+ * Maximum width for legend label text as a CSS value (e.g. '200px', '50%', '10rem').
20
+ * When set, text overflow behavior is controlled by textOverflow.
19
21
  */
20
22
  maxWidth?: string;
21
23
  /**
@@ -24,11 +26,35 @@ export type BaseLegendProps = Omit< LegendOrdinalProps, 'shapeStyle' > & {
24
26
  * - 'wrap': Wrap text to multiple lines (default, ideal for larger displays)
25
27
  */
26
28
  textOverflow?: 'ellipsis' | 'wrap';
29
+ };
30
+
31
+ export type LegendShapeStyles = {
32
+ /** Width of the legend shape in pixels. */
33
+ width?: number;
34
+ /** Height of the legend shape in pixels. */
35
+ height?: number;
36
+ /** Margin around the legend shape. */
37
+ margin?: CSSProperties[ 'margin' ];
38
+ };
39
+
40
+ export type BaseLegendProps = VisxLegendProps & {
41
+ items: BaseLegendItem[];
42
+ orientation?: 'horizontal' | 'vertical';
27
43
  /**
28
- * Additional CSS class name for legend items.
29
- * This allows consumers to customize individual legend item styling.
44
+ * TODO: Add 'left' | 'right' positioning support in future implementation
30
45
  */
31
- legendItemClassName?: string;
46
+ position?: 'top' | 'bottom';
47
+ alignment?: 'start' | 'center' | 'end';
48
+ /** Additional CSS class name for legend items. */
49
+ itemClassName?: string;
50
+ /** CSS styles for each legend item (margin, flexDirection). */
51
+ itemStyles?: LegendItemStyles;
52
+ /** Additional CSS class name for legend labels. */
53
+ labelClassName?: string;
54
+ /** CSS styles for legend labels (justifyContent, flex, margin). */
55
+ labelStyles?: LegendLabelStyles;
56
+ /** Styles for legend shapes (width, height, margin). */
57
+ shapeStyles?: LegendShapeStyles;
32
58
  /**
33
59
  * Function for rendering a custom legend layout.
34
60
  */
@@ -51,7 +77,7 @@ export type LegendProps = Omit< BaseLegendProps, 'items' > & {
51
77
 
52
78
  export type BaseLegendItem = {
53
79
  label: string;
54
- value: number | string;
80
+ value?: number | string;
55
81
  color: string;
56
82
  glyphSize?: number;
57
83
  renderGlyph?: < Datum extends object >( props: GlyphProps< Datum > ) => ReactNode;