@carto/ps-react-ui 5.2.0 → 5.4.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 (50) hide show
  1. package/dist/{actions-CF8RNKd6.js → actions-Dhlz_adK.js} +187 -181
  2. package/dist/actions-Dhlz_adK.js.map +1 -0
  3. package/dist/category-C6myUFUJ.js +936 -0
  4. package/dist/category-C6myUFUJ.js.map +1 -0
  5. package/dist/legend.js +34 -21
  6. package/dist/legend.js.map +1 -1
  7. package/dist/types/legend/stores/types.d.ts +4 -2
  8. package/dist/types/widgets-v2/actions/searcher/filter.d.ts +3 -2
  9. package/dist/types/widgets-v2/category/category-ui.d.ts +10 -2
  10. package/dist/types/widgets-v2/category/category.d.ts +7 -1
  11. package/dist/types/widgets-v2/category/components/category-bar-stacked.d.ts +8 -1
  12. package/dist/types/widgets-v2/category/components/category-bar.d.ts +24 -2
  13. package/dist/types/widgets-v2/category/components/category-row-multi.d.ts +3 -1
  14. package/dist/types/widgets-v2/category/components/category-row-single.d.ts +3 -1
  15. package/dist/types/widgets-v2/category/components/category-row-stacked.d.ts +10 -1
  16. package/dist/types/widgets-v2/category/style.d.ts +33 -6
  17. package/dist/types/widgets-v2/wrapper/style.d.ts +31 -2
  18. package/dist/types/widgets-v2/wrapper/widget-actions.d.ts +2 -2
  19. package/dist/types/widgets-v2/wrapper/widget-wrapper.d.ts +2 -2
  20. package/dist/widgets-v2/actions.js +1 -1
  21. package/dist/widgets-v2/category.js +1 -1
  22. package/dist/widgets-v2.js +342 -295
  23. package/dist/widgets-v2.js.map +1 -1
  24. package/package.json +1 -1
  25. package/src/legend/components/legend-item/legend-item-ui.test.tsx +22 -1
  26. package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +22 -6
  27. package/src/legend/stores/types.ts +4 -2
  28. package/src/widgets-v2/actions/searcher/filter.test.ts +28 -0
  29. package/src/widgets-v2/actions/searcher/filter.ts +22 -4
  30. package/src/widgets-v2/actions/searcher/searcher-toggle.tsx +9 -4
  31. package/src/widgets-v2/actions/searcher/searcher.test.tsx +22 -0
  32. package/src/widgets-v2/category/category-ui.test.tsx +139 -0
  33. package/src/widgets-v2/category/category-ui.tsx +64 -11
  34. package/src/widgets-v2/category/category.test.tsx +67 -0
  35. package/src/widgets-v2/category/category.tsx +32 -15
  36. package/src/widgets-v2/category/components/category-bar-stacked.test.tsx +18 -0
  37. package/src/widgets-v2/category/components/category-bar-stacked.tsx +42 -29
  38. package/src/widgets-v2/category/components/category-bar.test.tsx +66 -0
  39. package/src/widgets-v2/category/components/category-bar.tsx +65 -9
  40. package/src/widgets-v2/category/components/category-row-multi.tsx +21 -0
  41. package/src/widgets-v2/category/components/category-row-single.tsx +4 -0
  42. package/src/widgets-v2/category/components/category-row-stacked.tsx +24 -1
  43. package/src/widgets-v2/category/style.ts +58 -14
  44. package/src/widgets-v2/wrapper/style.ts +38 -2
  45. package/src/widgets-v2/wrapper/widget-actions.tsx +2 -2
  46. package/src/widgets-v2/wrapper/widget-wrapper.test.tsx +32 -2
  47. package/src/widgets-v2/wrapper/widget-wrapper.tsx +58 -32
  48. package/dist/actions-CF8RNKd6.js.map +0 -1
  49. package/dist/category-D01ruG-l.js +0 -842
  50. package/dist/category-D01ruG-l.js.map +0 -1
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { filterBySearchText } from './filter'
3
+
4
+ const DATA = [
5
+ [
6
+ { name: 'ES', value: 1 },
7
+ { name: 'FR', value: 2 },
8
+ ],
9
+ ]
10
+ const COUNTRIES: Record<string, string> = { ES: 'Spain', FR: 'France' }
11
+ const label = (v: string | number) => COUNTRIES[v] ?? v
12
+
13
+ describe('filterBySearchText', () => {
14
+ it('matches the raw name when there is no label formatter', () => {
15
+ expect(filterBySearchText(DATA, 'es')).toEqual([[{ name: 'ES', value: 1 }]])
16
+ })
17
+
18
+ it('matches the formatted label, which is what the user sees', () => {
19
+ expect(filterBySearchText(DATA, 'spa', label)).toEqual([
20
+ [{ name: 'ES', value: 1 }],
21
+ ])
22
+ })
23
+
24
+ it('no longer matches a raw name the label does not show', () => {
25
+ // "es" is in the raw code "ES" but in neither "Spain" nor "France".
26
+ expect(filterBySearchText(DATA, 'es', label)).toEqual([[]])
27
+ })
28
+ })
@@ -1,9 +1,14 @@
1
1
  /**
2
2
  * Filters a 2D series of `{name, ...}` records by case-insensitive substring on
3
- * the `name` field. Keeps the dataset shape (one inner array per series).
3
+ * the label the user sees: `labelFormatter(name)` when a formatter is given,
4
+ * `name` otherwise. Keeps the dataset shape (one inner array per series).
4
5
  * Used by the Searcher action's data transform.
5
6
  */
6
- export function filterBySearchText(input: unknown, search: string): unknown {
7
+ export function filterBySearchText(
8
+ input: unknown,
9
+ search: string,
10
+ labelFormatter?: (value: string | number) => string | number,
11
+ ): unknown {
7
12
  if (!search) return input
8
13
  const needle = search.toLowerCase()
9
14
  if (!Array.isArray(input)) return input
@@ -13,14 +18,27 @@ export function filterBySearchText(input: unknown, search: string): unknown {
13
18
  if (item == null) return false
14
19
  if (typeof item === 'object' && 'name' in item) {
15
20
  const rawName = (item as { name: unknown }).name
16
- const name = rawName == null ? '' : stringifyPrimitive(rawName)
17
- return name.toLowerCase().includes(needle)
21
+ return labelOf(rawName, labelFormatter).toLowerCase().includes(needle)
18
22
  }
19
23
  return stringifyPrimitive(item).toLowerCase().includes(needle)
20
24
  })
21
25
  })
22
26
  }
23
27
 
28
+ function labelOf(
29
+ name: unknown,
30
+ labelFormatter?: (value: string | number) => string | number,
31
+ ): string {
32
+ if (name == null) return ''
33
+ if (
34
+ labelFormatter &&
35
+ (typeof name === 'string' || typeof name === 'number')
36
+ ) {
37
+ return stringifyPrimitive(labelFormatter(name))
38
+ }
39
+ return stringifyPrimitive(name)
40
+ }
41
+
24
42
  function stringifyPrimitive(v: unknown): string {
25
43
  if (typeof v === 'string') return v
26
44
  if (typeof v === 'number' || typeof v === 'boolean') return String(v)
@@ -46,11 +46,16 @@ export function SearcherToggle({
46
46
  (s) => (s.transformStates.searcher?.searchText as string | undefined) ?? '',
47
47
  )
48
48
 
49
- // The transform fn closes over `text`. Memoize via useCallback so identity
50
- // only changes when text changes, allowing useTransform to re-register.
49
+ // Matched against the label the user sees, so a formatted name is found
50
+ // by what's on screen rather than by its raw value.
51
+ const labelFormatter = useWidget(id, (s) => s.labelFormatter)
52
+
53
+ // The transform fn closes over `text` and `labelFormatter`. Memoize via
54
+ // useCallback so identity only changes when either does, allowing
55
+ // useTransform to re-register.
51
56
  const fn = useCallback(
52
- (data: unknown) => filterBySearchText(data, text),
53
- [text],
57
+ (data: unknown) => filterBySearchText(data, text, labelFormatter),
58
+ [text, labelFormatter],
54
59
  )
55
60
 
56
61
  const { enabled, toggle } = useSingleTransform(id, SEARCHER_DESCRIPTOR, fn, {
@@ -89,4 +89,26 @@ describe('<Searcher> + <SearcherToggle>', () => {
89
89
  vi.useRealTimers()
90
90
  }
91
91
  })
92
+
93
+ it('filters by the formatted label the widget shows', () => {
94
+ const labelFormatter = (v: string | number) =>
95
+ ({ apple: 'Manzana', banana: 'Plátano' })[String(v)] ?? v
96
+ render(
97
+ <Provider id='sr-label' data={DATA} labelFormatter={labelFormatter}>
98
+ <SearcherToggle initialEnabled />
99
+ <Searcher />
100
+ </Provider>,
101
+ )
102
+ act(() =>
103
+ getWidgetStore('sr-label').setState((s) => ({
104
+ transformStates: {
105
+ ...s.transformStates,
106
+ searcher: { ...s.transformStates.searcher!, searchText: 'manz' },
107
+ },
108
+ })),
109
+ )
110
+ expect(getWidgetStore('sr-label').getState().data).toEqual([
111
+ [{ name: 'apple', value: 1 }],
112
+ ])
113
+ })
92
114
  })
@@ -906,4 +906,143 @@ describe('<CategoryUI>', () => {
906
906
  fills.forEach((f) => expect(f.style.backgroundColor).toBe(DIM))
907
907
  })
908
908
  })
909
+
910
+ // ── Signed values ──────────────────────────────────────────────────
911
+ describe('negative values', () => {
912
+ const MIXED = [
913
+ [
914
+ { name: 'Income', value: 0.8 },
915
+ { name: 'Transit', value: -0.4 },
916
+ { name: 'Size', value: 0 },
917
+ ],
918
+ ]
919
+ const fills = (c: HTMLElement) =>
920
+ Array.from(c.querySelectorAll<HTMLElement>('[data-bar-fill="true"]'))
921
+ const ticks = (c: HTMLElement) =>
922
+ Array.from(c.querySelectorAll<HTMLElement>('[data-zero-tick="true"]'))
923
+
924
+ it('centres the zero with a symmetric domain and keeps the series color', () => {
925
+ const { container } = render(<CategoryUI data={MIXED} />)
926
+ const [income, transit, size] = fills(container)
927
+ expect([income!.style.left, income!.style.width]).toEqual(['50%', '50%'])
928
+ expect([transit!.style.left, transit!.style.width]).toEqual([
929
+ '25%',
930
+ '25%',
931
+ ])
932
+ expect(size!.style.width).toBe('0%')
933
+ expect(transit!.style.backgroundColor).toBe(income!.style.backgroundColor)
934
+ })
935
+
936
+ it('marks the zero with one tick per bar, none on "Others"', () => {
937
+ const { container } = render(<CategoryUI data={MIXED} maxItems={2} />)
938
+ expect(ticks(container).map((t) => t.style.left)).toEqual(['50%', '50%'])
939
+ expect(screen.getByText('Others')).toBeTruthy()
940
+ })
941
+
942
+ it('draws one zero line per multi-series row, spanning every bar', () => {
943
+ const { container } = render(
944
+ <CategoryUI
945
+ data={[
946
+ [
947
+ { name: 'A', value: 1 },
948
+ { name: 'B', value: 2 },
949
+ ],
950
+ [
951
+ { name: 'A', value: -1 },
952
+ { name: 'B', value: 0.5 },
953
+ ],
954
+ [
955
+ { name: 'A', value: 0 },
956
+ { name: 'B', value: -2 },
957
+ ],
958
+ ]}
959
+ />,
960
+ )
961
+ const t = ticks(container)
962
+ expect(t).toHaveLength(2)
963
+ expect(t[0]!.style.left).toBe('50%')
964
+ expect(t[0]!.parentElement!.style.gridRow).toBe('2 / span 3')
965
+ })
966
+
967
+ it.each([
968
+ ['positive-only', [1, 2]],
969
+ ['negative-only', [-1, -2]],
970
+ ['all-zero', [0, 0]],
971
+ ])('draws no zero tick for %s data', (_, values) => {
972
+ const { container } = render(
973
+ <CategoryUI
974
+ data={[values.map((value, i) => ({ name: `R${i}`, value }))]}
975
+ />,
976
+ )
977
+ expect(ticks(container)).toHaveLength(0)
978
+ })
979
+
980
+ it('hangs negative-only bars from the right edge', () => {
981
+ const { container } = render(
982
+ <CategoryUI
983
+ data={[
984
+ [
985
+ { name: 'A', value: -2 },
986
+ { name: 'B', value: -1 },
987
+ ],
988
+ ]}
989
+ />,
990
+ )
991
+ const [a, b] = fills(container)
992
+ expect([a!.style.left, a!.style.width]).toEqual(['0%', '100%'])
993
+ expect([b!.style.left, b!.style.width]).toEqual(['50%', '50%'])
994
+ })
995
+
996
+ it('treats maxOverride as a magnitude for signed data', () => {
997
+ const { container } = render(
998
+ <CategoryUI data={MIXED} maxOverride={1.6} />,
999
+ )
1000
+ const [income, transit] = fills(container)
1001
+ // domain [-1.6, 1.6]
1002
+ expect([income!.style.left, income!.style.width]).toEqual(['50%', '25%'])
1003
+ expect(transit!.style.width).toBe('12.5%')
1004
+ })
1005
+
1006
+ it('minOverride pins the zero even when the data has no negatives', () => {
1007
+ const { container } = render(
1008
+ <CategoryUI data={[[{ name: 'A', value: 1 }]]} minOverride={-1} />,
1009
+ )
1010
+ expect(fills(container)[0]!.style.left).toBe('50%')
1011
+ expect(ticks(container)).toHaveLength(1)
1012
+ })
1013
+
1014
+ it('prefixes U+2212 itself and hands the formatter |v|', () => {
1015
+ const formatter = vi.fn((n: number) => `$${n}`)
1016
+ render(
1017
+ <CategoryUI
1018
+ data={[
1019
+ [
1020
+ { name: 'A', value: -0.5 },
1021
+ { name: 'B', value: -0 },
1022
+ { name: 'C', value: 0 },
1023
+ ],
1024
+ ]}
1025
+ formatter={formatter}
1026
+ />,
1027
+ )
1028
+ expect(screen.getByText('\u2212$0.5')).toBeTruthy()
1029
+ expect(screen.getAllByText('$0')).toHaveLength(2)
1030
+ formatter.mock.calls.forEach(([n]) => expect(n).toBeGreaterThanOrEqual(0))
1031
+ })
1032
+
1033
+ it('stacked mode diverges around the shared zero and shows the signed total', () => {
1034
+ const { container } = render(
1035
+ <CategoryUI
1036
+ stacked
1037
+ data={[[{ name: 'A', value: 3 }], [{ name: 'A', value: -1 }]]}
1038
+ />,
1039
+ )
1040
+ const [pos, neg] = fills(container)
1041
+ // zero at 50%, longer side (3) fills its half
1042
+ expect([pos!.style.left, pos!.style.width]).toEqual(['50%', '50%'])
1043
+ expect(parseFloat(neg!.style.left)).toBeCloseTo(50 - 50 / 3)
1044
+ expect(screen.getByText('2')).toBeTruthy()
1045
+ expect(screen.getByText('Series 2: \u22121')).toBeTruthy()
1046
+ })
1047
+ })
909
1048
  })
@@ -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
@@ -161,6 +169,42 @@ function generateGroupedData(data: CategoryWidgetData): GroupedRow[] {
161
169
  return order.map((k) => grouped.get(k)!)
162
170
  }
163
171
 
172
+ /**
173
+ * Bar domain `[lo, hi]`, always containing zero. Positive-only data keeps
174
+ * the zero at the left edge and negative-only at the right; mixed data is
175
+ * made symmetric so the zero sits in the middle of the track.
176
+ */
177
+ function signedDomain(
178
+ rows: readonly GroupedRow[],
179
+ minOverride?: number,
180
+ maxOverride?: number,
181
+ ): [lo: number, hi: number] {
182
+ let lo = 0
183
+ let hi = 0
184
+ for (const row of rows) {
185
+ for (const v of row.values) {
186
+ if (v < lo) lo = v
187
+ if (v > hi) hi = v
188
+ }
189
+ }
190
+ if (typeof minOverride === 'number' && minOverride < 0) lo = minOverride
191
+ if (typeof maxOverride === 'number' && maxOverride > 0) hi = maxOverride
192
+ if (lo < 0 && hi > 0) {
193
+ const m = Math.max(-lo, hi)
194
+ return [-m, m]
195
+ }
196
+ return [lo, hi]
197
+ }
198
+
199
+ /**
200
+ * The widget owns the sign: `formatter` only ever sees `|v|`, so a formatter
201
+ * that prefixes a unit reads `−$5`, not `$-5`, and one that emits its own
202
+ * `-` cannot double it. U+2212 is as wide as a tabular digit.
203
+ */
204
+ function formatSigned(fmt: (n: number) => string, v: number): string {
205
+ return (v < 0 ? '\u2212' : '') + fmt(Math.abs(v))
206
+ }
207
+
164
208
  /**
165
209
  * Pure presentational component for the Category widget. Renders
166
210
  * single-series rows (`data.length === 1`) as a compact grid or
@@ -187,13 +231,15 @@ export function CategoryUI({
187
231
  labels,
188
232
  onShowAll,
189
233
  maxOverride,
234
+ minOverride,
190
235
  size = 'small',
191
236
  stacked = false,
192
237
  hiddenSeries: hiddenSeriesProp,
193
238
  onHiddenSeriesChange,
194
239
  }: CategoryUIProps) {
195
240
  const theme = useTheme()
196
- const fmt = formatter ?? ((n: number) => String(n))
241
+ const baseFmt = formatter ?? ((n: number) => String(n))
242
+ const fmt = (n: number) => formatSigned(baseFmt, n)
197
243
 
198
244
  const hasSelection = !!selection && selection.length > 0
199
245
  const selectionSet = useMemo(
@@ -245,14 +291,14 @@ export function CategoryUI({
245
291
 
246
292
  const grouped = useMemo(() => generateGroupedData(data), [data])
247
293
 
248
- const maxValue = useMemo(() => {
249
- if (typeof maxOverride === 'number' && maxOverride > 0) return maxOverride
250
- let m = 0
251
- for (const row of grouped) {
252
- for (const v of row.values) if (v > m) m = v
253
- }
254
- return m
255
- }, [grouped, maxOverride])
294
+ const [minValue, maxValue] = useMemo(
295
+ () => signedDomain(grouped, minOverride, maxOverride),
296
+ [grouped, minOverride, maxOverride],
297
+ )
298
+ // Where the zero sits on the track, handed to stacked rows so their zero
299
+ // matches the other rows'. Positive-only data keeps it at the left edge.
300
+ const zeroPct =
301
+ maxValue > minValue ? (-minValue / (maxValue - minValue)) * 100 : 0
256
302
 
257
303
  // Three modes:
258
304
  // * positive finite N (default `DEFAULT_MAX_ITEMS = 20` when prop
@@ -382,7 +428,11 @@ export function CategoryUI({
382
428
  <Box sx={styles.root}>
383
429
  <Box
384
430
  ref={listRefCallback}
385
- sx={scrollMode ? { ...styles.list, ...styles.listScroll } : styles.list}
431
+ sx={{
432
+ ...styles.list,
433
+ ...(isMulti && !stacked && styles.listMulti),
434
+ ...(scrollMode && styles.listScroll),
435
+ }}
386
436
  style={
387
437
  scrollMaxHeight !== undefined
388
438
  ? { maxHeight: scrollMaxHeight, overflowY: 'auto' }
@@ -432,6 +482,7 @@ export function CategoryUI({
432
482
  selected={selected}
433
483
  onToggle={toggle}
434
484
  size={size}
485
+ zeroPct={zeroPct}
435
486
  />
436
487
  )
437
488
  }
@@ -443,6 +494,7 @@ export function CategoryUI({
443
494
  values={visibleIdx.map((i) => row.values[i]!)}
444
495
  colors={visibleIdx.map((i) => fill(i))}
445
496
  maxValue={maxValue}
497
+ minValue={minValue}
446
498
  formatter={fmt}
447
499
  selected={selected}
448
500
  onToggle={toggle}
@@ -457,6 +509,7 @@ export function CategoryUI({
457
509
  displayName={displayName}
458
510
  value={row.values[0] ?? 0}
459
511
  maxValue={maxValue}
512
+ minValue={minValue}
460
513
  color={fill(0)}
461
514
  formatter={fmt}
462
515
  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
  })