@carto/ps-react-ui 4.24.0 → 4.25.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 (27) hide show
  1. package/dist/category-DuKx_CJX.js +933 -0
  2. package/dist/category-DuKx_CJX.js.map +1 -0
  3. package/dist/types/widgets-v2/category/category-ui.d.ts +10 -2
  4. package/dist/types/widgets-v2/category/category.d.ts +7 -1
  5. package/dist/types/widgets-v2/category/components/category-bar-stacked.d.ts +8 -1
  6. package/dist/types/widgets-v2/category/components/category-bar.d.ts +24 -2
  7. package/dist/types/widgets-v2/category/components/category-row-multi.d.ts +3 -1
  8. package/dist/types/widgets-v2/category/components/category-row-single.d.ts +3 -1
  9. package/dist/types/widgets-v2/category/components/category-row-stacked.d.ts +10 -1
  10. package/dist/types/widgets-v2/category/style.d.ts +33 -6
  11. package/dist/widgets-v2/category.js +1 -1
  12. package/dist/widgets-v2.js +1 -1
  13. package/package.json +1 -1
  14. package/src/widgets-v2/category/category-ui.test.tsx +139 -0
  15. package/src/widgets-v2/category/category-ui.tsx +64 -11
  16. package/src/widgets-v2/category/category.test.tsx +67 -0
  17. package/src/widgets-v2/category/category.tsx +32 -15
  18. package/src/widgets-v2/category/components/category-bar-stacked.test.tsx +18 -0
  19. package/src/widgets-v2/category/components/category-bar-stacked.tsx +42 -29
  20. package/src/widgets-v2/category/components/category-bar.test.tsx +66 -0
  21. package/src/widgets-v2/category/components/category-bar.tsx +65 -9
  22. package/src/widgets-v2/category/components/category-row-multi.tsx +21 -0
  23. package/src/widgets-v2/category/components/category-row-single.tsx +4 -0
  24. package/src/widgets-v2/category/components/category-row-stacked.tsx +24 -1
  25. package/src/widgets-v2/category/style.ts +58 -14
  26. package/dist/category-Ca5XDx0N.js +0 -839
  27. package/dist/category-Ca5XDx0N.js.map +0 -1
@@ -814,4 +814,143 @@ describe('<CategoryUI>', () => {
814
814
  fills.forEach((f) => expect(f.style.backgroundColor).toBe(DIM))
815
815
  })
816
816
  })
817
+
818
+ // ── Signed values ──────────────────────────────────────────────────
819
+ describe('negative values', () => {
820
+ const MIXED = [
821
+ [
822
+ { name: 'Income', value: 0.8 },
823
+ { name: 'Transit', value: -0.4 },
824
+ { name: 'Size', value: 0 },
825
+ ],
826
+ ]
827
+ const fills = (c: HTMLElement) =>
828
+ Array.from(c.querySelectorAll<HTMLElement>('[data-bar-fill="true"]'))
829
+ const ticks = (c: HTMLElement) =>
830
+ Array.from(c.querySelectorAll<HTMLElement>('[data-zero-tick="true"]'))
831
+
832
+ it('centres the zero with a symmetric domain and keeps the series color', () => {
833
+ const { container } = render(<CategoryUI data={MIXED} />)
834
+ const [income, transit, size] = fills(container)
835
+ expect([income!.style.left, income!.style.width]).toEqual(['50%', '50%'])
836
+ expect([transit!.style.left, transit!.style.width]).toEqual([
837
+ '25%',
838
+ '25%',
839
+ ])
840
+ expect(size!.style.width).toBe('0%')
841
+ expect(transit!.style.backgroundColor).toBe(income!.style.backgroundColor)
842
+ })
843
+
844
+ it('marks the zero with one tick per bar, none on "Others"', () => {
845
+ const { container } = render(<CategoryUI data={MIXED} maxItems={2} />)
846
+ expect(ticks(container).map((t) => t.style.left)).toEqual(['50%', '50%'])
847
+ expect(screen.getByText('Others')).toBeTruthy()
848
+ })
849
+
850
+ it('draws one zero line per multi-series row, spanning every bar', () => {
851
+ const { container } = render(
852
+ <CategoryUI
853
+ data={[
854
+ [
855
+ { name: 'A', value: 1 },
856
+ { name: 'B', value: 2 },
857
+ ],
858
+ [
859
+ { name: 'A', value: -1 },
860
+ { name: 'B', value: 0.5 },
861
+ ],
862
+ [
863
+ { name: 'A', value: 0 },
864
+ { name: 'B', value: -2 },
865
+ ],
866
+ ]}
867
+ />,
868
+ )
869
+ const t = ticks(container)
870
+ expect(t).toHaveLength(2)
871
+ expect(t[0]!.style.left).toBe('50%')
872
+ expect(t[0]!.parentElement!.style.gridRow).toBe('2 / span 3')
873
+ })
874
+
875
+ it.each([
876
+ ['positive-only', [1, 2]],
877
+ ['negative-only', [-1, -2]],
878
+ ['all-zero', [0, 0]],
879
+ ])('draws no zero tick for %s data', (_, values) => {
880
+ const { container } = render(
881
+ <CategoryUI
882
+ data={[values.map((value, i) => ({ name: `R${i}`, value }))]}
883
+ />,
884
+ )
885
+ expect(ticks(container)).toHaveLength(0)
886
+ })
887
+
888
+ it('hangs negative-only bars from the right edge', () => {
889
+ const { container } = render(
890
+ <CategoryUI
891
+ data={[
892
+ [
893
+ { name: 'A', value: -2 },
894
+ { name: 'B', value: -1 },
895
+ ],
896
+ ]}
897
+ />,
898
+ )
899
+ const [a, b] = fills(container)
900
+ expect([a!.style.left, a!.style.width]).toEqual(['0%', '100%'])
901
+ expect([b!.style.left, b!.style.width]).toEqual(['50%', '50%'])
902
+ })
903
+
904
+ it('treats maxOverride as a magnitude for signed data', () => {
905
+ const { container } = render(
906
+ <CategoryUI data={MIXED} maxOverride={1.6} />,
907
+ )
908
+ const [income, transit] = fills(container)
909
+ // domain [-1.6, 1.6]
910
+ expect([income!.style.left, income!.style.width]).toEqual(['50%', '25%'])
911
+ expect(transit!.style.width).toBe('12.5%')
912
+ })
913
+
914
+ it('minOverride pins the zero even when the data has no negatives', () => {
915
+ const { container } = render(
916
+ <CategoryUI data={[[{ name: 'A', value: 1 }]]} minOverride={-1} />,
917
+ )
918
+ expect(fills(container)[0]!.style.left).toBe('50%')
919
+ expect(ticks(container)).toHaveLength(1)
920
+ })
921
+
922
+ it('prefixes U+2212 itself and hands the formatter |v|', () => {
923
+ const formatter = vi.fn((n: number) => `$${n}`)
924
+ render(
925
+ <CategoryUI
926
+ data={[
927
+ [
928
+ { name: 'A', value: -0.5 },
929
+ { name: 'B', value: -0 },
930
+ { name: 'C', value: 0 },
931
+ ],
932
+ ]}
933
+ formatter={formatter}
934
+ />,
935
+ )
936
+ expect(screen.getByText('\u2212$0.5')).toBeTruthy()
937
+ expect(screen.getAllByText('$0')).toHaveLength(2)
938
+ formatter.mock.calls.forEach(([n]) => expect(n).toBeGreaterThanOrEqual(0))
939
+ })
940
+
941
+ it('stacked mode diverges around the shared zero and shows the signed total', () => {
942
+ const { container } = render(
943
+ <CategoryUI
944
+ stacked
945
+ data={[[{ name: 'A', value: 3 }], [{ name: 'A', value: -1 }]]}
946
+ />,
947
+ )
948
+ const [pos, neg] = fills(container)
949
+ // zero at 50%, longer side (3) fills its half
950
+ expect([pos!.style.left, pos!.style.width]).toEqual(['50%', '50%'])
951
+ expect(parseFloat(neg!.style.left)).toBeCloseTo(50 - 50 / 3)
952
+ expect(screen.getByText('2')).toBeTruthy()
953
+ expect(screen.getByText('Series 2: \u22121')).toBeTruthy()
954
+ })
955
+ })
817
956
  })
@@ -78,8 +78,16 @@ export interface CategoryUIProps {
78
78
  * overflow row stays a static summary.
79
79
  */
80
80
  onShowAll?: () => void
81
- /** Manual override for the bar-width denominator. */
81
+ /**
82
+ * Manual override for the top of the bar scale. With signed data it is a
83
+ * magnitude: the scale stays symmetric around the centred zero.
84
+ */
82
85
  maxOverride?: number
86
+ /**
87
+ * Manual override for the bottom of the bar scale, the negative mirror of
88
+ * `maxOverride`. Ignored unless negative.
89
+ */
90
+ minOverride?: number
83
91
  /**
84
92
  * Visual density of the bar primitive. `'small'` (default) keeps the
85
93
  * historical 4px-tall pill; `'medium'` switches to a 12px-tall track
@@ -147,6 +155,42 @@ function generateGroupedData(data: CategoryWidgetData): GroupedRow[] {
147
155
  return order.map((k) => grouped.get(k)!)
148
156
  }
149
157
 
158
+ /**
159
+ * Bar domain `[lo, hi]`, always containing zero. Positive-only data keeps
160
+ * the zero at the left edge and negative-only at the right; mixed data is
161
+ * made symmetric so the zero sits in the middle of the track.
162
+ */
163
+ function signedDomain(
164
+ rows: readonly GroupedRow[],
165
+ minOverride?: number,
166
+ maxOverride?: number,
167
+ ): [lo: number, hi: number] {
168
+ let lo = 0
169
+ let hi = 0
170
+ for (const row of rows) {
171
+ for (const v of row.values) {
172
+ if (v < lo) lo = v
173
+ if (v > hi) hi = v
174
+ }
175
+ }
176
+ if (typeof minOverride === 'number' && minOverride < 0) lo = minOverride
177
+ if (typeof maxOverride === 'number' && maxOverride > 0) hi = maxOverride
178
+ if (lo < 0 && hi > 0) {
179
+ const m = Math.max(-lo, hi)
180
+ return [-m, m]
181
+ }
182
+ return [lo, hi]
183
+ }
184
+
185
+ /**
186
+ * The widget owns the sign: `formatter` only ever sees `|v|`, so a formatter
187
+ * that prefixes a unit reads `−$5`, not `$-5`, and one that emits its own
188
+ * `-` cannot double it. U+2212 is as wide as a tabular digit.
189
+ */
190
+ function formatSigned(fmt: (n: number) => string, v: number): string {
191
+ return (v < 0 ? '\u2212' : '') + fmt(Math.abs(v))
192
+ }
193
+
150
194
  /**
151
195
  * Pure presentational component for the Category widget. Renders
152
196
  * single-series rows (`data.length === 1`) as a compact grid or
@@ -173,13 +217,15 @@ export function CategoryUI({
173
217
  labels,
174
218
  onShowAll,
175
219
  maxOverride,
220
+ minOverride,
176
221
  size = 'small',
177
222
  stacked = false,
178
223
  hiddenSeries: hiddenSeriesProp,
179
224
  onHiddenSeriesChange,
180
225
  }: CategoryUIProps) {
181
226
  const theme = useTheme()
182
- const fmt = formatter ?? ((n: number) => String(n))
227
+ const baseFmt = formatter ?? ((n: number) => String(n))
228
+ const fmt = (n: number) => formatSigned(baseFmt, n)
183
229
 
184
230
  const hasSelection = !!selection && selection.length > 0
185
231
  const selectionSet = useMemo(
@@ -231,14 +277,14 @@ export function CategoryUI({
231
277
 
232
278
  const grouped = useMemo(() => generateGroupedData(data), [data])
233
279
 
234
- const maxValue = useMemo(() => {
235
- if (typeof maxOverride === 'number' && maxOverride > 0) return maxOverride
236
- let m = 0
237
- for (const row of grouped) {
238
- for (const v of row.values) if (v > m) m = v
239
- }
240
- return m
241
- }, [grouped, maxOverride])
280
+ const [minValue, maxValue] = useMemo(
281
+ () => signedDomain(grouped, minOverride, maxOverride),
282
+ [grouped, minOverride, maxOverride],
283
+ )
284
+ // Where the zero sits on the track, handed to stacked rows so their zero
285
+ // matches the other rows'. Positive-only data keeps it at the left edge.
286
+ const zeroPct =
287
+ maxValue > minValue ? (-minValue / (maxValue - minValue)) * 100 : 0
242
288
 
243
289
  // Three modes:
244
290
  // * positive finite N (default `DEFAULT_MAX_ITEMS = 20` when prop
@@ -363,7 +409,11 @@ export function CategoryUI({
363
409
  <Box sx={styles.root}>
364
410
  <Box
365
411
  ref={listRefCallback}
366
- sx={scrollMode ? { ...styles.list, ...styles.listScroll } : styles.list}
412
+ sx={{
413
+ ...styles.list,
414
+ ...(isMulti && !stacked && styles.listMulti),
415
+ ...(scrollMode && styles.listScroll),
416
+ }}
367
417
  style={
368
418
  scrollMaxHeight !== undefined
369
419
  ? { maxHeight: scrollMaxHeight, overflowY: 'auto' }
@@ -413,6 +463,7 @@ export function CategoryUI({
413
463
  selected={selected}
414
464
  onToggle={toggle}
415
465
  size={size}
466
+ zeroPct={zeroPct}
416
467
  />
417
468
  )
418
469
  }
@@ -424,6 +475,7 @@ export function CategoryUI({
424
475
  values={visibleIdx.map((i) => row.values[i]!)}
425
476
  colors={visibleIdx.map((i) => fill(i))}
426
477
  maxValue={maxValue}
478
+ minValue={minValue}
427
479
  formatter={fmt}
428
480
  selected={selected}
429
481
  onToggle={toggle}
@@ -438,6 +490,7 @@ export function CategoryUI({
438
490
  displayName={displayName}
439
491
  value={row.values[0] ?? 0}
440
492
  maxValue={maxValue}
493
+ minValue={minValue}
441
494
  color={fill(0)}
442
495
  formatter={fmt}
443
496
  selected={selected}
@@ -238,6 +238,73 @@ describe('<Category> bridge', () => {
238
238
  expect(fills[0]!.style.width).toBe('25%')
239
239
  })
240
240
 
241
+ it('keeps the zero centred when a transform hides every negative row', () => {
242
+ render(
243
+ <Provider
244
+ id='cat-bridge-auto-neg'
245
+ data={[
246
+ [
247
+ { name: 'A', value: 1 },
248
+ { name: 'B', value: -1 },
249
+ ],
250
+ ]}
251
+ >
252
+ <Category />
253
+ </Provider>,
254
+ )
255
+ act(() => {
256
+ getWidgetStore('cat-bridge-auto-neg').setState({
257
+ data: [[{ name: 'A', value: 1 }]],
258
+ })
259
+ })
260
+ const fills = getBarFills()
261
+ expect(fills).toHaveLength(1)
262
+ expect(fills[0]!.style.left).toBe('50%')
263
+ expect(fills[0]!.style.width).toBe('50%')
264
+ })
265
+
266
+ it.each([
267
+ [
268
+ 'positive-only',
269
+ [50, 25],
270
+ [
271
+ ['0%', '50%'],
272
+ ['0%', '25%'],
273
+ ],
274
+ ],
275
+ [
276
+ 'negative-only',
277
+ [-50, -25],
278
+ [
279
+ ['50%', '50%'],
280
+ ['75%', '25%'],
281
+ ],
282
+ ],
283
+ [
284
+ 'mixed',
285
+ [50, -25],
286
+ [
287
+ ['50%', '25%'],
288
+ ['37.5%', '12.5%'],
289
+ ],
290
+ ],
291
+ ])(
292
+ 'applies ±100 overrides only on the side %s data reaches',
293
+ (_, values, expected) => {
294
+ render(
295
+ <Provider
296
+ id={`cat-bridge-side-${values.join('')}`}
297
+ data={[values.map((value, i) => ({ name: `R${i}`, value }))]}
298
+ >
299
+ <Category maxOverride={100} minOverride={-100} />
300
+ </Provider>,
301
+ )
302
+ expect(getBarFills().map((f) => [f.style.left, f.style.width])).toEqual(
303
+ expected,
304
+ )
305
+ },
306
+ )
307
+
241
308
  it('consumer-supplied maxOverride wins over the rawData auto-fill', () => {
242
309
  render(
243
310
  <Provider id='cat-bridge-auto-3' data={PEAK_DATA}>
@@ -42,13 +42,19 @@ interface CategoryWidgetState extends WidgetState {
42
42
 
43
43
  const NO_HIDDEN: ReadonlySet<number> = new Set()
44
44
 
45
- function maxFromCategoryData(d: CategoryWidgetData | undefined): number {
46
- if (!d) return 0
47
- let m = 0
48
- for (const series of d) {
49
- for (const item of series) if (item.value > m) m = item.value
45
+ /** `[min, max]` of every value, each clamped to include zero. */
46
+ function extentFromCategoryData(
47
+ d: CategoryWidgetData | undefined,
48
+ ): [min: number, max: number] {
49
+ let min = 0
50
+ let max = 0
51
+ for (const series of d ?? []) {
52
+ for (const item of series) {
53
+ if (item.value < min) min = item.value
54
+ if (item.value > max) max = item.value
55
+ }
50
56
  }
51
- return m
57
+ return [min, max]
52
58
  }
53
59
 
54
60
  export interface CategoryProps {
@@ -84,6 +90,12 @@ export interface CategoryProps {
84
90
  * regardless of the data.
85
91
  */
86
92
  maxOverride?: number
93
+ /**
94
+ * Bottom of the bar scale for negative values, the mirror of
95
+ * `maxOverride`. Auto-filled from `rawData` the same way, so the zero
96
+ * does not move when the Searcher hides every negative row.
97
+ */
98
+ minOverride?: number
87
99
  /**
88
100
  * Visual density of the bar primitive. `'small'` (default) keeps the
89
101
  * historical 4px-tall pill; `'medium'` switches to a 12px-tall track
@@ -113,23 +125,27 @@ export function Category({
113
125
  labels,
114
126
  onShowAll,
115
127
  maxOverride,
128
+ minOverride,
116
129
  size,
117
130
  stacked,
118
131
  }: CategoryProps) {
119
132
  const id = useWidgetId()
120
133
  const slice = useWidgetShallow(id, categorySelector)
121
- // Auto-fill `maxOverride` from `rawData` so bar widths stay coherent
122
- // across data transforms (e.g., the Searcher filtering rows in/out).
123
- // Consumer's explicit value always wins; we only fill when omitted
124
- // AND `rawData` has at least one positive value (CategoryUI's
125
- // `maxOverride > 0` guard would reject 0 anyway, but skipping is
126
- // cleaner — it lets the live fallback path handle the empty case).
127
- const naturalMax = useMemo(
128
- () => maxFromCategoryData(slice.rawData),
134
+ // Auto-fill both ends of the scale from `rawData` so bar widths and the
135
+ // zero stay coherent across data transforms (e.g., the Searcher filtering
136
+ // rows in/out). Consumer's explicit value wins, but each end only applies
137
+ // when `rawData` reaches past zero on that side: a relative-data wiring can
138
+ // then pass `100` / `-100` unconditionally and positive-only data keeps its
139
+ // zero at the left edge, negative-only at the right. With no data on a side
140
+ // we pass nothing and CategoryUI falls back to the live `data`.
141
+ const [naturalMin, naturalMax] = useMemo(
142
+ () => extentFromCategoryData(slice.rawData),
129
143
  [slice.rawData],
130
144
  )
131
145
  const effectiveMaxOverride =
132
- maxOverride ?? (naturalMax > 0 ? naturalMax : undefined)
146
+ naturalMax > 0 ? (maxOverride ?? naturalMax) : undefined
147
+ const effectiveMinOverride =
148
+ naturalMin < 0 ? (minOverride ?? naturalMin) : undefined
133
149
 
134
150
  const seriesKey = JSON.stringify(series?.map((s) => s.name) ?? [])
135
151
  const stored = useWidget<
@@ -156,6 +172,7 @@ export function Category({
156
172
  labels={labels}
157
173
  onShowAll={onShowAll}
158
174
  maxOverride={effectiveMaxOverride}
175
+ minOverride={effectiveMinOverride}
159
176
  size={size}
160
177
  stacked={stacked}
161
178
  hiddenSeries={hiddenSeries}
@@ -118,4 +118,22 @@ describe('<CategoryBarStacked>', () => {
118
118
  const track = container.querySelector('[data-size]')
119
119
  expect(track!.getAttribute('data-size')).toBe('medium')
120
120
  })
121
+
122
+ it('diverges from zeroPct: positives stack rightwards, negatives leftwards', () => {
123
+ const { container } = render(
124
+ <CategoryBarStacked
125
+ values={[20, -10, 30, -20]}
126
+ colors={['a', 'b', 'c', 'd']}
127
+ maxValue={100}
128
+ zeroPct={50}
129
+ />,
130
+ )
131
+ const segs = getSegments(container)
132
+ expect(segs.map((s) => [s.style.left, s.style.width])).toEqual([
133
+ ['50%', '20%'],
134
+ ['40%', '10%'],
135
+ ['70%', '30%'],
136
+ ['20%', '20%'],
137
+ ])
138
+ })
121
139
  })
@@ -1,6 +1,7 @@
1
1
  import { Box } from '@mui/material'
2
2
  import type { CategorySize } from '../types'
3
3
  import { styles } from '../style'
4
+ import { ZeroTickWrap } from './category-bar'
4
5
 
5
6
  export interface CategoryBarStackedProps {
6
7
  /** One value per series. Zero values render no segment (visually + in the DOM). */
@@ -13,6 +14,13 @@ export interface CategoryBarStackedProps {
13
14
  * so cross-row comparison is preserved.
14
15
  */
15
16
  maxValue: number
17
+ /**
18
+ * Where the zero sits, as a percentage of the track. `0` (default) is the
19
+ * positive-only layout. Positive segments stack rightwards from it and
20
+ * negative ones leftwards, both scaled by `maxValue` against the full
21
+ * track width.
22
+ */
23
+ zeroPct?: number
16
24
  /** Visual density. Forwarded to the track styling; segments inherit height. */
17
25
  size?: CategorySize
18
26
  }
@@ -33,41 +41,46 @@ export function CategoryBarStacked({
33
41
  values,
34
42
  colors,
35
43
  maxValue,
44
+ zeroPct = 0,
36
45
  size = 'small',
37
46
  }: CategoryBarStackedProps) {
38
- // Cumulative offsets pre-computed up front so the render map function
39
- // stays pure (no mutable closure variable — keeps the React Compiler
40
- // immutability check happy).
41
- const offsets: number[] = []
47
+ // Left edges pre-computed up front so the render map function stays pure
48
+ // (no mutable closure variable — keeps the React Compiler immutability
49
+ // check happy). Each sign keeps its own running sum away from the zero.
50
+ const lefts: number[] = []
42
51
  {
43
- let acc = 0
52
+ let pos = 0
53
+ let neg = 0
44
54
  for (const v of values) {
45
- offsets.push(acc)
46
- acc += v
55
+ if (v < 0) neg -= v
56
+ lefts.push(v < 0 ? -neg : pos)
57
+ if (v > 0) pos += v
47
58
  }
48
59
  }
49
60
  return (
50
- <Box sx={styles.bar[size]} data-size={size}>
51
- {values.map((v, i) => {
52
- if (v <= 0) return null
53
- const leftPct = maxValue > 0 ? (offsets[i]! / maxValue) * 100 : 0
54
- const pct = maxValue > 0 ? (v / maxValue) * 100 : 0
55
- return (
56
- <Box
57
- // Segments are keyed by per-series colour (the only stable
58
- // distinguishing field on this side) plus the segment index for
59
- // when callers reuse palette colours across series.
60
- key={`seg-${colors[i] ?? 'none'}-${i}`}
61
- sx={styles.stackedSegment[size]}
62
- style={{
63
- left: `${leftPct}%`,
64
- width: `${pct}%`,
65
- backgroundColor: colors[i] ?? '',
66
- }}
67
- data-bar-fill='true'
68
- />
69
- )
70
- })}
71
- </Box>
61
+ <ZeroTickWrap zeroPct={zeroPct > 0 && zeroPct < 100 ? zeroPct : undefined}>
62
+ <Box sx={styles.bar[size]} data-size={size}>
63
+ {values.map((v, i) => {
64
+ if (v === 0 || maxValue <= 0) return null
65
+ const leftPct = zeroPct + (lefts[i]! / maxValue) * 100
66
+ const pct = (Math.abs(v) / maxValue) * 100
67
+ return (
68
+ <Box
69
+ // Segments are keyed by per-series colour (the only stable
70
+ // distinguishing field on this side) plus the segment index for
71
+ // when callers reuse palette colours across series.
72
+ key={`seg-${colors[i] ?? 'none'}-${i}`}
73
+ sx={styles.stackedSegment[size]}
74
+ style={{
75
+ left: `${leftPct}%`,
76
+ width: `${pct}%`,
77
+ backgroundColor: colors[i] ?? '',
78
+ }}
79
+ data-bar-fill='true'
80
+ />
81
+ )
82
+ })}
83
+ </Box>
84
+ </ZeroTickWrap>
72
85
  )
73
86
  }
@@ -61,4 +61,70 @@ describe('<CategoryBar>', () => {
61
61
  expect(track).toBeTruthy()
62
62
  expect(track!.getAttribute('data-size')).toBe('medium')
63
63
  })
64
+
65
+ describe('signed domain (minValue < 0)', () => {
66
+ it('grows a positive value rightwards from the zero', () => {
67
+ const { container } = render(
68
+ <CategoryBar
69
+ value={0.5}
70
+ minValue={-1}
71
+ maxValue={1}
72
+ color='rgb(0, 0, 0)'
73
+ />,
74
+ )
75
+ const fill = getFill(container)!
76
+ expect(fill.style.left).toBe('50%')
77
+ expect(fill.style.width).toBe('25%')
78
+ })
79
+
80
+ it('grows a negative value leftwards from the zero, in the same color', () => {
81
+ const { container } = render(
82
+ <CategoryBar
83
+ value={-0.5}
84
+ minValue={-1}
85
+ maxValue={1}
86
+ color='rgb(0, 200, 0)'
87
+ />,
88
+ )
89
+ const fill = getFill(container)!
90
+ expect(fill.style.left).toBe('25%')
91
+ expect(fill.style.width).toBe('25%')
92
+ expect(fill.style.backgroundColor).toBe('rgb(0, 200, 0)')
93
+ })
94
+
95
+ it('squares the end that grows out of the zero', () => {
96
+ const { container: pos } = render(
97
+ <CategoryBar value={1} minValue={-1} maxValue={1} color='red' />,
98
+ )
99
+ const { container: neg } = render(
100
+ <CategoryBar value={-1} minValue={-1} maxValue={1} color='red' />,
101
+ )
102
+ expect(getFill(pos)!.style.borderTopLeftRadius).toBe('0px')
103
+ expect(getFill(pos)!.style.borderTopRightRadius).toBe('')
104
+ expect(getFill(neg)!.style.borderTopRightRadius).toBe('0px')
105
+ expect(getFill(neg)!.style.borderTopLeftRadius).toBe('')
106
+ })
107
+
108
+ it('puts the zero tick before the track so the bar paints over it', () => {
109
+ const { container } = render(
110
+ <CategoryBar value={1} minValue={-1} maxValue={1} color='red' />,
111
+ )
112
+ const tick = container.querySelector('[data-zero-tick="true"]')!
113
+ expect(tick.nextElementSibling!.getAttribute('data-size')).toBe('small')
114
+ })
115
+
116
+ it('hangs from the right edge when the domain is negative-only', () => {
117
+ const { container } = render(
118
+ <CategoryBar
119
+ value={-1}
120
+ minValue={-4}
121
+ maxValue={0}
122
+ color='rgb(0, 0, 0)'
123
+ />,
124
+ )
125
+ const fill = getFill(container)!
126
+ expect(fill.style.left).toBe('75%')
127
+ expect(fill.style.width).toBe('25%')
128
+ })
129
+ })
64
130
  })