@carto/ps-react-ui 6.0.0 → 6.1.1

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 (142) hide show
  1. package/dist/actions-BWlS5tpP.js +1150 -0
  2. package/dist/actions-BWlS5tpP.js.map +1 -0
  3. package/dist/category-alzjKzvQ.js +839 -0
  4. package/dist/category-alzjKzvQ.js.map +1 -0
  5. package/dist/{download-Vlzn2jzb.js → download-B4sykOFm.js} +94 -83
  6. package/dist/download-B4sykOFm.js.map +1 -0
  7. package/dist/{echart-DjcbtKgi.js → echart-CV8F50gn.js} +6 -5
  8. package/dist/echart-CV8F50gn.js.map +1 -0
  9. package/dist/{formula-CdHWtepq.js → formula-DJS-JLpk.js} +10 -16
  10. package/dist/{formula-CdHWtepq.js.map → formula-DJS-JLpk.js.map} +1 -1
  11. package/dist/legend.js +34 -21
  12. package/dist/legend.js.map +1 -1
  13. package/dist/{markdown-CY-67NRE.js → markdown-Bn2nKCbn.js} +12 -18
  14. package/dist/{markdown-CY-67NRE.js.map → markdown-Bn2nKCbn.js.map} +1 -1
  15. package/dist/{range-DHQSsl2_.js → range-BpU2zBmq.js} +2 -2
  16. package/dist/{range-DHQSsl2_.js.map → range-BpU2zBmq.js.map} +1 -1
  17. package/dist/{spread-DuM5oTI1.js → spread-CQwFIwuz.js} +10 -16
  18. package/dist/{spread-DuM5oTI1.js.map → spread-CQwFIwuz.js.map} +1 -1
  19. package/dist/{stores-B8CHbgw-.js → stores-DcBOZlU3.js} +30 -24
  20. package/dist/stores-DcBOZlU3.js.map +1 -0
  21. package/dist/{table-BE4uO9pp.js → table-DEdW_fDj.js} +7 -13
  22. package/dist/{table-BE4uO9pp.js.map → table-DEdW_fDj.js.map} +1 -1
  23. package/dist/types/legend/stores/types.d.ts +4 -2
  24. package/dist/types/widgets/actions/download/csv-item.d.ts +3 -3
  25. package/dist/types/widgets/actions/download/index.d.ts +2 -1
  26. package/dist/types/widgets/actions/download/png-item.d.ts +2 -2
  27. package/dist/types/widgets/actions/download/types.d.ts +12 -1
  28. package/dist/types/widgets/actions/download/widget-download.d.ts +26 -0
  29. package/dist/types/widgets/actions/fullscreen/fullscreen.d.ts +20 -13
  30. package/dist/types/widgets/actions/index.d.ts +1 -1
  31. package/dist/types/widgets/actions/searcher/filter.d.ts +3 -2
  32. package/dist/types/widgets/actions/searcher/searcher-toggle.d.ts +3 -0
  33. package/dist/types/widgets/bar/download.d.ts +6 -20
  34. package/dist/types/widgets/category/category-ui.d.ts +9 -1
  35. package/dist/types/widgets/category/download.d.ts +5 -10
  36. package/dist/types/widgets/category/helpers.d.ts +10 -0
  37. package/dist/types/widgets/echart/style.d.ts +1 -0
  38. package/dist/types/widgets/formula/download.d.ts +5 -15
  39. package/dist/types/widgets/histogram/download.d.ts +3 -8
  40. package/dist/types/widgets/index.d.ts +1 -1
  41. package/dist/types/widgets/markdown/download.d.ts +5 -11
  42. package/dist/types/widgets/pie/download.d.ts +3 -8
  43. package/dist/types/widgets/scatterplot/download.d.ts +6 -11
  44. package/dist/types/widgets/spread/download.d.ts +6 -12
  45. package/dist/types/widgets/stores/index.d.ts +1 -1
  46. package/dist/types/widgets/stores/widget-store-registry.d.ts +14 -0
  47. package/dist/types/widgets/table/download.d.ts +7 -13
  48. package/dist/types/widgets/timeseries/download.d.ts +7 -12
  49. package/dist/types/widgets/toolbox/toolbox.d.ts +1 -1
  50. package/dist/types/widgets/wrapper/style.d.ts +31 -2
  51. package/dist/types/widgets/wrapper/widget-actions.d.ts +2 -2
  52. package/dist/types/widgets/wrapper/widget-wrapper.d.ts +2 -2
  53. package/dist/{utils-BStJWhZN.js → utils-C-eWwOhE.js} +2 -2
  54. package/dist/{utils-BStJWhZN.js.map → utils-C-eWwOhE.js.map} +1 -1
  55. package/dist/widgets/actions.js +4 -4
  56. package/dist/widgets/bar.js +11 -17
  57. package/dist/widgets/bar.js.map +1 -1
  58. package/dist/widgets/category.js +1 -1
  59. package/dist/widgets/echart.js +1 -1
  60. package/dist/widgets/formula.js +1 -1
  61. package/dist/widgets/histogram.js +14 -20
  62. package/dist/widgets/histogram.js.map +1 -1
  63. package/dist/widgets/markdown.js +1 -1
  64. package/dist/widgets/pie.js +13 -19
  65. package/dist/widgets/pie.js.map +1 -1
  66. package/dist/widgets/range.js +1 -1
  67. package/dist/widgets/scatterplot.js +13 -19
  68. package/dist/widgets/scatterplot.js.map +1 -1
  69. package/dist/widgets/spread.js +1 -1
  70. package/dist/widgets/stores.js +2 -2
  71. package/dist/widgets/table.js +1 -1
  72. package/dist/widgets/timeseries.js +16 -22
  73. package/dist/widgets/timeseries.js.map +1 -1
  74. package/dist/widgets/utils.js +1 -1
  75. package/dist/widgets.js +364 -376
  76. package/dist/widgets.js.map +1 -1
  77. package/dist/{zoom-toggle-D_VuA01l.js → zoom-toggle-MmLjMTEw.js} +2 -2
  78. package/dist/{zoom-toggle-D_VuA01l.js.map → zoom-toggle-MmLjMTEw.js.map} +1 -1
  79. package/package.json +1 -1
  80. package/src/legend/components/legend-item/legend-item-ui.test.tsx +22 -1
  81. package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +22 -6
  82. package/src/legend/stores/types.ts +4 -2
  83. package/src/widgets/actions/download/csv-item.test.tsx +3 -3
  84. package/src/widgets/actions/download/csv-item.tsx +6 -6
  85. package/src/widgets/actions/download/download.tsx +5 -3
  86. package/src/widgets/actions/download/index.ts +2 -1
  87. package/src/widgets/actions/download/png-item.test.tsx +5 -3
  88. package/src/widgets/actions/download/png-item.tsx +4 -4
  89. package/src/widgets/actions/download/types.ts +15 -1
  90. package/src/widgets/actions/download/widget-download.test.ts +30 -0
  91. package/src/widgets/actions/download/widget-download.ts +41 -0
  92. package/src/widgets/actions/fullscreen/fullscreen-store.test.tsx +155 -0
  93. package/src/widgets/actions/fullscreen/fullscreen.test.tsx +39 -41
  94. package/src/widgets/actions/fullscreen/fullscreen.tsx +198 -96
  95. package/src/widgets/actions/index.ts +3 -0
  96. package/src/widgets/actions/searcher/filter.test.ts +28 -0
  97. package/src/widgets/actions/searcher/filter.ts +22 -4
  98. package/src/widgets/actions/searcher/searcher-toggle.tsx +16 -8
  99. package/src/widgets/actions/searcher/searcher.test.tsx +22 -0
  100. package/src/widgets/actions/searcher/searcher.tsx +17 -7
  101. package/src/widgets/bar/download.test.tsx +5 -3
  102. package/src/widgets/bar/download.tsx +11 -35
  103. package/src/widgets/category/category-ui.tsx +26 -14
  104. package/src/widgets/category/category.tsx +37 -2
  105. package/src/widgets/category/components/category-legend.tsx +1 -2
  106. package/src/widgets/category/download.test.ts +4 -2
  107. package/src/widgets/category/download.ts +14 -27
  108. package/src/widgets/category/helpers.test.ts +17 -0
  109. package/src/widgets/category/helpers.ts +16 -0
  110. package/src/widgets/echart/style.ts +6 -0
  111. package/src/widgets/formula/download.test.tsx +3 -1
  112. package/src/widgets/formula/download.tsx +10 -30
  113. package/src/widgets/histogram/download.test.ts +6 -4
  114. package/src/widgets/histogram/download.ts +13 -26
  115. package/src/widgets/index.ts +1 -0
  116. package/src/widgets/markdown/download.test.ts +2 -2
  117. package/src/widgets/markdown/download.tsx +11 -26
  118. package/src/widgets/pie/download.test.ts +7 -3
  119. package/src/widgets/pie/download.ts +12 -25
  120. package/src/widgets/scatterplot/download.test.ts +4 -2
  121. package/src/widgets/scatterplot/download.ts +15 -28
  122. package/src/widgets/spread/download.test.ts +3 -1
  123. package/src/widgets/spread/download.ts +13 -27
  124. package/src/widgets/stores/index.ts +2 -0
  125. package/src/widgets/stores/widget-store-registry.test.ts +25 -0
  126. package/src/widgets/stores/widget-store-registry.ts +22 -0
  127. package/src/widgets/table/download.test.ts +5 -3
  128. package/src/widgets/table/download.ts +15 -29
  129. package/src/widgets/timeseries/download.test.ts +6 -4
  130. package/src/widgets/timeseries/download.ts +16 -29
  131. package/src/widgets/toolbox/toolbox.tsx +1 -1
  132. package/src/widgets/wrapper/style.ts +38 -2
  133. package/src/widgets/wrapper/widget-actions.tsx +2 -2
  134. package/src/widgets/wrapper/widget-wrapper.test.tsx +32 -2
  135. package/src/widgets/wrapper/widget-wrapper.tsx +62 -36
  136. package/dist/actions-KsX1xdDM.js +0 -1024
  137. package/dist/actions-KsX1xdDM.js.map +0 -1
  138. package/dist/category-CsdDqDzo.js +0 -833
  139. package/dist/category-CsdDqDzo.js.map +0 -1
  140. package/dist/download-Vlzn2jzb.js.map +0 -1
  141. package/dist/echart-DjcbtKgi.js.map +0 -1
  142. package/dist/stores-B8CHbgw-.js.map +0 -1
@@ -3,8 +3,8 @@ import { IconButton, InputAdornment, TextField } from '@mui/material'
3
3
  import { Search as SearchIcon } from '@mui/icons-material'
4
4
  import { Clear as ClearIcon } from '@mui/icons-material'
5
5
  import { debounce } from '@carto/ps-utils'
6
- import { getWidgetStore, useWidget, useWidgetId } from '../../stores'
7
- import { setSearcherText } from './searcher-toggle'
6
+ import { useWidget, useWidgetId } from '../../stores'
7
+ import { selectSearchText, setSearcherText } from './searcher-toggle'
8
8
  import { DEFAULT_SEARCHER_LABELS, type SearcherLabels } from './labels'
9
9
  import { styles } from './style'
10
10
 
@@ -33,12 +33,20 @@ export function Searcher({
33
33
 
34
34
  const inputRef = useRef<HTMLInputElement | null>(null)
35
35
 
36
+ const stored = useWidget(id, selectSearchText)
36
37
  // Hydrate from the store so re-toggling the input preserves any prior text.
37
- const [local, setLocal] = useState<string>(
38
- () =>
39
- (getWidgetStore(id).getState().transformStates.searcher?.searchText as
40
- string | undefined) ?? '',
41
- )
38
+ const [local, setLocal] = useState(stored)
39
+
40
+ // Follow text written by another Searcher of the same widget (the card
41
+ // and the fullscreen copy each render one). Skipped while this input has
42
+ // focus: its own debounced write lands late, and adopting it would drop
43
+ // whatever was typed since.
44
+ const [focused, setFocused] = useState(false)
45
+ const [prevStored, setPrevStored] = useState(stored)
46
+ if (stored !== prevStored) {
47
+ setPrevStored(stored)
48
+ if (!focused) setLocal(stored)
49
+ }
42
50
 
43
51
  const debouncedWrite = useMemo(
44
52
  () =>
@@ -78,6 +86,8 @@ export function Searcher({
78
86
  placeholder={resolved.placeholder}
79
87
  value={local}
80
88
  onChange={handleSearchTextChange}
89
+ onFocus={() => setFocused(true)}
90
+ onBlur={() => setFocused(false)}
81
91
  // The Searcher only mounts when the user explicitly toggles the
82
92
  // SearcherToggle action — focusing the input on mount matches user
83
93
  // intent, so the jsx-a11y/no-autofocus warning is the false-positive
@@ -50,7 +50,9 @@ describe('createBarDownloadConfig', () => {
50
50
  })
51
51
  const png = items.find((i) => i.id === 'png')
52
52
  expect(png).toBeTruthy()
53
- await expect(png!.resolve()).rejects.toThrow(/No PNG capture element/)
53
+ await expect(png!.resolve({ id: null })).rejects.toThrow(
54
+ /No PNG capture element/,
55
+ )
54
56
  })
55
57
 
56
58
  it('CSV resolve returns a download handle with the filename + revoke fn (single series)', async () => {
@@ -59,7 +61,7 @@ describe('createBarDownloadConfig', () => {
59
61
  getData: () => sample,
60
62
  })
61
63
  const csv = items.find((i) => i.id === 'csv')!
62
- const handle = await csv.resolve()
64
+ const handle = await csv.resolve({ id: null })
63
65
  expect(handle.url).toBe('blob:mock')
64
66
  expect(handle.filename).toBe('sales.csv')
65
67
  expect(typeof handle.revoke).toBe('function')
@@ -84,7 +86,7 @@ describe('createBarDownloadConfig', () => {
84
86
  filename: 'multi',
85
87
  getData: () => multi,
86
88
  })
87
- await items.find((i) => i.id === 'csv')!.resolve()
89
+ await items.find((i) => i.id === 'csv')!.resolve({ id: null })
88
90
  expect(csvText).toBe('name,value\na,1\nb,2\n\nname,value\nc,3')
89
91
  vi.unstubAllGlobals()
90
92
  })
@@ -1,51 +1,28 @@
1
1
  import {
2
2
  buildCsvDownloadItem,
3
- buildPngDownloadItem,
3
+ withPngItem,
4
4
  type DownloadItem,
5
+ type WidgetDownloadArgs,
5
6
  } from '../actions/download'
6
7
  import type { BarWidgetData } from './types'
7
8
 
8
- export interface BarDownloadConfigArgs {
9
- filename: string
10
- getData: () => BarWidgetData
11
- /**
12
- * Optional getter for the widget's capture element (registered by
13
- * `Widget.State` on its success path). When provided, a PNG item is
14
- * prepended to the returned list. Wire it to
15
- * `() => getCaptureEl(id)`.
16
- */
17
- getCaptureEl?: () => HTMLElement | null
18
- /** PNG `pixelRatio` (default 2). */
19
- pngPixelRatio?: number
20
- /** PNG `backgroundColor` (default transparent). */
21
- pngBackgroundColor?: string | null
22
- }
9
+ export type BarDownloadConfigArgs = WidgetDownloadArgs<BarWidgetData>
23
10
 
24
11
  /**
25
- * Builds download items for the Bar widget. Always includes a CSV item
26
- * with one `name,value` block per series, separated by an empty row when
27
- * there are multiple. When `getCaptureEl` is supplied, also prepends a PNG
28
- * item that rasterises the captured element via `html2canvas`.
12
+ * Builds download items for the Bar widget. Always includes a CSV item with
13
+ * one `name,value` block per series, separated by an empty row when there
14
+ * are multiple. When `getCaptureEl` is given, also prepends a PNG item that
15
+ * rasterises the captured element via `html2canvas`.
29
16
  */
30
17
  export function createBarDownloadConfig(
31
18
  args: BarDownloadConfigArgs,
32
19
  ): DownloadItem[] {
33
- const items: DownloadItem[] = []
34
- if (args.getCaptureEl) {
35
- items.push(
36
- buildPngDownloadItem({
37
- filename: args.filename,
38
- getCaptureEl: args.getCaptureEl,
39
- pixelRatio: args.pngPixelRatio,
40
- backgroundColor: args.pngBackgroundColor,
41
- }),
42
- )
43
- }
44
- items.push(
20
+ return withPngItem(
21
+ args,
45
22
  buildCsvDownloadItem({
46
23
  filename: args.filename,
47
- getRows: () => {
48
- const data = args.getData()
24
+ getRows: (ctx) => {
25
+ const data = args.getData(ctx)
49
26
  const rows: unknown[][] = []
50
27
  for (const [i, series] of data.entries()) {
51
28
  if (i > 0) rows.push([])
@@ -56,5 +33,4 @@ export function createBarDownloadConfig(
56
33
  },
57
34
  }),
58
35
  )
59
- return items
60
36
  }
@@ -13,6 +13,7 @@ import type {
13
13
  CategoryWidgetData,
14
14
  } from './types'
15
15
  import { styles } from './style'
16
+ import { categorySeriesKey } from './helpers'
16
17
  import { resolveThemeColor } from '../utils'
17
18
 
18
19
  const DEFAULT_MAX_ITEMS = 20
@@ -97,6 +98,14 @@ export interface CategoryUIProps {
97
98
  * No-op for single-series data — same convention as the legend.
98
99
  */
99
100
  stacked?: boolean
101
+ /**
102
+ * Series indices hidden via the legend. Leave both this and
103
+ * `onHiddenSeriesChange` unset and CategoryUI keeps the set itself; pass
104
+ * them to own it — `Category` does, so the card and the fullscreen copy
105
+ * share it through the widget store.
106
+ */
107
+ hiddenSeries?: ReadonlySet<number>
108
+ onHiddenSeriesChange?: (next: ReadonlySet<number>) => void
100
109
  }
101
110
 
102
111
  interface GroupedRow {
@@ -182,6 +191,8 @@ export function CategoryUI({
182
191
  maxOverride,
183
192
  size = 'small',
184
193
  stacked = false,
194
+ hiddenSeries: hiddenSeriesProp,
195
+ onHiddenSeriesChange,
185
196
  }: CategoryUIProps) {
186
197
  const theme = useTheme()
187
198
  const fmt = formatter ?? ((n: number) => String(n))
@@ -304,9 +315,10 @@ export function CategoryUI({
304
315
  // selection, unrelated). Never allowed to reach "every series hidden":
305
316
  // toggling the last visible one instead resets to "all visible" rather
306
317
  // than leaving the widget blank.
307
- const [hiddenSeries, setHiddenSeries] = useState<ReadonlySet<number>>(
318
+ const [localHidden, setLocalHidden] = useState<ReadonlySet<number>>(
308
319
  () => new Set(),
309
320
  )
321
+ const hiddenSeries = hiddenSeriesProp ?? localHidden
310
322
  // A reload with a different series breakdown invalidates hidden indices —
311
323
  // index 2 may now point at an entirely different series. Reset rather
312
324
  // than keep hiding whatever now occupies a stale index. Adjusted directly
@@ -317,11 +329,11 @@ export function CategoryUI({
317
329
  // `series={[...]}` literal (no useMemo) gets a new reference on every
318
330
  // unrelated parent re-render, which would otherwise reset hiddenSeries
319
331
  // right after the user hides one.
320
- const seriesKey = JSON.stringify(series?.map((s) => s.name) ?? [])
332
+ const seriesKey = categorySeriesKey(series)
321
333
  const [prevSeriesKey, setPrevSeriesKey] = useState(seriesKey)
322
334
  if (seriesKey !== prevSeriesKey) {
323
335
  setPrevSeriesKey(seriesKey)
324
- setHiddenSeries(new Set())
336
+ setLocalHidden(new Set())
325
337
  }
326
338
  // Real number of data series — NOT `series?.length`, which is legend
327
339
  // metadata and may legitimately be shorter than the actual series count
@@ -331,18 +343,18 @@ export function CategoryUI({
331
343
  const seriesCount = Math.max(data.length, 1)
332
344
  const toggleSeries = useCallback(
333
345
  (index: number) => {
334
- setHiddenSeries((prev) => {
335
- // Start from what the user sees, not the stored set: `data` can
336
- // shrink while the `series` names hold (the reset only watches the
337
- // names), and a stale index would otherwise count toward the guard
338
- // below — or make unchecking one series hide a different one.
339
- const next = effectiveHidden(prev, seriesCount)
340
- if (next.has(index)) next.delete(index)
341
- else next.add(index)
342
- return next.size >= seriesCount ? new Set() : next
343
- })
346
+ // Start from what the user sees, not the stored set: `data` can
347
+ // shrink while the `series` names hold (the reset only watches the
348
+ // names), and a stale index would otherwise count toward the guard
349
+ // below — or make unchecking one series hide a different one.
350
+ const next = effectiveHidden(hiddenSeries, seriesCount)
351
+ if (next.has(index)) next.delete(index)
352
+ else next.add(index)
353
+ ;(onHiddenSeriesChange ?? setLocalHidden)(
354
+ next.size >= seriesCount ? new Set() : next,
355
+ )
344
356
  },
345
- [seriesCount],
357
+ [hiddenSeries, onHiddenSeriesChange, seriesCount],
346
358
  )
347
359
 
348
360
  if (grouped.length === 0) return null
@@ -1,6 +1,13 @@
1
- import { useMemo } from 'react'
2
- import { useWidgetId, useWidgetShallow, type WidgetState } from '../stores'
1
+ import { useCallback, useMemo } from 'react'
2
+ import {
3
+ getWidgetStore,
4
+ useWidget,
5
+ useWidgetId,
6
+ useWidgetShallow,
7
+ type WidgetState,
8
+ } from '../stores'
3
9
  import { CategoryUI } from './category-ui'
10
+ import { categorySeriesKey, NO_HIDDEN_SERIES } from './helpers'
4
11
  import type {
5
12
  CategoryKey,
6
13
  CategoryLabels,
@@ -23,6 +30,17 @@ const categorySelector = (s: WidgetState): CategorySlice => ({
23
30
  labelFormatter: s.labelFormatter,
24
31
  })
25
32
 
33
+ /**
34
+ * Legend-hidden series, kept on the widget store so every mounted Category
35
+ * of the widget — the card and its fullscreen copy — shows the same set.
36
+ * Tagged with the series breakdown it was made for: after a reload with
37
+ * different series the indices point elsewhere, so a stale set reads as
38
+ * empty instead of being reset with a write.
39
+ */
40
+ interface CategoryWidgetState extends WidgetState {
41
+ categoryHiddenSeries?: { key: string; indices: ReadonlySet<number> }
42
+ }
43
+
26
44
  function maxFromCategoryData(d: CategoryWidgetData | undefined): number {
27
45
  if (!d) return 0
28
46
  let m = 0
@@ -111,6 +129,21 @@ export function Category({
111
129
  )
112
130
  const effectiveMaxOverride =
113
131
  maxOverride ?? (naturalMax > 0 ? naturalMax : undefined)
132
+
133
+ const seriesKey = categorySeriesKey(series)
134
+ const stored = useWidget<
135
+ CategoryWidgetState['categoryHiddenSeries'],
136
+ CategoryWidgetState
137
+ >(id, (s) => s.categoryHiddenSeries)
138
+ const hiddenSeries =
139
+ stored?.key === seriesKey ? stored.indices : NO_HIDDEN_SERIES
140
+ const onHiddenSeriesChange = useCallback(
141
+ (indices: ReadonlySet<number>) =>
142
+ getWidgetStore(id).setState({
143
+ categoryHiddenSeries: { key: seriesKey, indices },
144
+ } as Partial<CategoryWidgetState>),
145
+ [id, seriesKey],
146
+ )
114
147
  return (
115
148
  <CategoryUI
116
149
  data={slice.data}
@@ -125,6 +158,8 @@ export function Category({
125
158
  maxOverride={effectiveMaxOverride}
126
159
  size={size}
127
160
  stacked={stacked}
161
+ hiddenSeries={hiddenSeries}
162
+ onHiddenSeriesChange={onHiddenSeriesChange}
128
163
  />
129
164
  )
130
165
  }
@@ -2,6 +2,7 @@ import type { CssColor } from '../../../components/types'
2
2
  import { Box, Typography } from '@mui/material'
3
3
  import type { CategorySeriesConfig } from '../types'
4
4
  import { styles } from '../style'
5
+ import { NO_HIDDEN_SERIES } from '../helpers'
5
6
 
6
7
  export interface CategoryLegendProps {
7
8
  /** Series metadata. Empty array → renders nothing. */
@@ -30,8 +31,6 @@ export interface CategoryLegendProps {
30
31
  onToggle?: (index: number) => void
31
32
  }
32
33
 
33
- const NO_HIDDEN_SERIES: ReadonlySet<number> = new Set()
34
-
35
34
  /**
36
35
  * Sticky color legend rendered below a multi-series Category list.
37
36
  * Per-series `color` overrides the matching `colorAt(i)` result. Clicking an
@@ -60,7 +60,9 @@ describe('createCategoryDownloadConfig', () => {
60
60
  filename: 'c',
61
61
  getData: () => data,
62
62
  })
63
- const handle = await items.find((i) => i.id === 'csv')!.resolve()
63
+ const handle = await items
64
+ .find((i) => i.id === 'csv')!
65
+ .resolve({ id: null })
64
66
  expect(handle.filename).toBe('c.csv')
65
67
  expect(csvText).toBe(
66
68
  'series,name,value\nseries_1,a,1\nseries_1,b,2\nseries_2,a,10',
@@ -73,7 +75,7 @@ describe('createCategoryDownloadConfig', () => {
73
75
  getData: () => data,
74
76
  seriesNames: ['baseline', 'override'],
75
77
  })
76
- await items.find((i) => i.id === 'csv')!.resolve()
78
+ await items.find((i) => i.id === 'csv')!.resolve({ id: null })
77
79
  expect(csvText.split('\n')[1]).toBe('baseline,a,1')
78
80
  expect(csvText.split('\n')[3]).toBe('override,a,10')
79
81
  })
@@ -1,40 +1,28 @@
1
1
  import {
2
2
  buildCsvDownloadItem,
3
- buildPngDownloadItem,
3
+ withPngItem,
4
4
  type DownloadItem,
5
+ type WidgetDownloadArgs,
5
6
  } from '../actions/download'
6
7
  import type { CategoryWidgetData } from './types'
7
8
 
8
9
  /**
9
10
  * Download menu items for the Category widget. Always includes a CSV item
10
- * with `series, name, value` columns (one row per item). When
11
- * `getCaptureEl` is supplied, prepends a PNG item that rasterises the
12
- * captured element via `html2canvas`.
11
+ * with `series, name, value` columns (one row per item). When `getCaptureEl`
12
+ * is given, prepends a PNG item that rasterises the captured element via
13
+ * `html2canvas`.
13
14
  */
14
- export function createCategoryDownloadConfig(args: {
15
- filename: string
16
- getData: () => CategoryWidgetData
17
- seriesNames?: readonly string[]
18
- getCaptureEl?: () => HTMLElement | null
19
- pngPixelRatio?: number
20
- pngBackgroundColor?: string | null
21
- }): DownloadItem[] {
22
- const items: DownloadItem[] = []
23
- if (args.getCaptureEl) {
24
- items.push(
25
- buildPngDownloadItem({
26
- filename: args.filename,
27
- getCaptureEl: args.getCaptureEl,
28
- pixelRatio: args.pngPixelRatio,
29
- backgroundColor: args.pngBackgroundColor,
30
- }),
31
- )
32
- }
33
- items.push(
15
+ export function createCategoryDownloadConfig(
16
+ args: WidgetDownloadArgs<CategoryWidgetData> & {
17
+ seriesNames?: readonly string[]
18
+ },
19
+ ): DownloadItem[] {
20
+ return withPngItem(
21
+ args,
34
22
  buildCsvDownloadItem({
35
23
  filename: args.filename,
36
- getRows: () => {
37
- const data = args.getData()
24
+ getRows: (ctx) => {
25
+ const data = args.getData(ctx)
38
26
  const rows: unknown[][] = [['series', 'name', 'value']]
39
27
  for (const [i, series] of data.entries()) {
40
28
  const seriesName = args.seriesNames?.[i] ?? `series_${i + 1}`
@@ -46,5 +34,4 @@ export function createCategoryDownloadConfig(args: {
46
34
  },
47
35
  }),
48
36
  )
49
- return items
50
37
  }
@@ -0,0 +1,17 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { categorySeriesKey } from './helpers'
3
+
4
+ describe('categorySeriesKey', () => {
5
+ it('depends on the series names only', () => {
6
+ expect(categorySeriesKey([{ name: 'A' }, { name: 'B' }])).toBe(
7
+ categorySeriesKey([{ name: 'A', color: 'red' }, { name: 'B' }]),
8
+ )
9
+ expect(categorySeriesKey([{ name: 'A' }])).not.toBe(
10
+ categorySeriesKey([{ name: 'B' }]),
11
+ )
12
+ })
13
+
14
+ it('treats no series as an empty breakdown', () => {
15
+ expect(categorySeriesKey(undefined)).toBe(categorySeriesKey([]))
16
+ })
17
+ })
@@ -0,0 +1,16 @@
1
+ import type { CategorySeriesConfig } from './types'
2
+
3
+ /** Shared empty set: "no series hidden" without a new identity per render. */
4
+ export const NO_HIDDEN_SERIES: ReadonlySet<number> = new Set()
5
+
6
+ /**
7
+ * Identifies a series breakdown by its names. Hidden-series indices are only
8
+ * valid for the breakdown they were made for, and both the store copy (in
9
+ * `Category`) and the local one (in `CategoryUI`) are tagged with this key —
10
+ * so they must compute it the same way.
11
+ */
12
+ export function categorySeriesKey(
13
+ series: readonly CategorySeriesConfig[] | undefined,
14
+ ): string {
15
+ return JSON.stringify(series?.map((s) => s.name) ?? [])
16
+ }
@@ -13,5 +13,11 @@ export const styles = {
13
13
  // with `<Widget.Echart sx={{ minHeight }} />`.
14
14
  flex: 1,
15
15
  minHeight: 304,
16
+ // ECharts puts its tooltip inside this box at `z-index: 9999999`. With
17
+ // no stacking context of its own, that number competes page-wide and
18
+ // beats MUI's modal and popover layers, so a tooltip left showing
19
+ // paints over any dialog or menu opened above the chart. Isolating
20
+ // keeps the tooltip above its own chart only.
21
+ isolation: 'isolate',
16
22
  },
17
23
  } satisfies Record<string, SxProps<Theme>>
@@ -56,7 +56,9 @@ describe('createFormulaDownloadConfig', () => {
56
56
  filename: 'f',
57
57
  getData: () => data,
58
58
  })
59
- const handle = await items.find((i) => i.id === 'csv')!.resolve()
59
+ const handle = await items
60
+ .find((i) => i.id === 'csv')!
61
+ .resolve({ id: null })
60
62
  expect(handle.filename).toBe('f.csv')
61
63
  expect(csvText).toBe(
62
64
  'series,prefix,value,suffix,note,delta\nRev,$,42,k,q1,12\n,,10,,,',
@@ -1,47 +1,28 @@
1
1
  import {
2
2
  buildCsvDownloadItem,
3
- buildPngDownloadItem,
3
+ withPngItem,
4
4
  type DownloadItem,
5
+ type WidgetDownloadArgs,
5
6
  } from '../actions/download'
6
7
  import type { FormulaWidgetData } from './types'
7
8
 
8
- export interface FormulaDownloadConfigArgs {
9
- filename: string
10
- getData: () => FormulaWidgetData
11
- /**
12
- * Optional getter for the widget's capture element (registered by
13
- * `Widget.State`). When provided, a PNG item is prepended.
14
- */
15
- getCaptureEl?: () => HTMLElement | null
16
- pngPixelRatio?: number
17
- pngBackgroundColor?: string | null
18
- }
9
+ export type FormulaDownloadConfigArgs = WidgetDownloadArgs<FormulaWidgetData>
19
10
 
20
11
  /**
21
12
  * Builds download items for the Formula widget. Always includes a CSV item
22
- * with one row per KPI (`prefix`, `value`, `suffix`, `note`, `delta`).
23
- * When `getCaptureEl` is supplied, prepends a PNG item that rasterises the
24
- * captured element via `html2canvas`.
13
+ * with one row per KPI (`prefix`, `value`, `suffix`, `note`, `delta`). When
14
+ * `getCaptureEl` is given, prepends a PNG item that rasterises the captured
15
+ * element via `html2canvas`.
25
16
  */
26
17
  export function createFormulaDownloadConfig(
27
18
  args: FormulaDownloadConfigArgs,
28
19
  ): DownloadItem[] {
29
- const items: DownloadItem[] = []
30
- if (args.getCaptureEl) {
31
- items.push(
32
- buildPngDownloadItem({
33
- filename: args.filename,
34
- getCaptureEl: args.getCaptureEl,
35
- pixelRatio: args.pngPixelRatio,
36
- backgroundColor: args.pngBackgroundColor,
37
- }),
38
- )
39
- }
40
- items.push(
20
+ return withPngItem(
21
+ args,
41
22
  buildCsvDownloadItem({
42
23
  filename: args.filename,
43
- getRows: () => {
44
- const data = args.getData()
24
+ getRows: (ctx) => {
25
+ const data = args.getData(ctx)
45
26
  const rows: unknown[][] = [
46
27
  ['series', 'prefix', 'value', 'suffix', 'note', 'delta'],
47
28
  ]
@@ -59,5 +40,4 @@ export function createFormulaDownloadConfig(
59
40
  },
60
41
  }),
61
42
  )
62
- return items
63
43
  }
@@ -52,7 +52,9 @@ describe('createHistogramDownloadConfig', () => {
52
52
  getData: () => data,
53
53
  getTicks: () => ticks,
54
54
  })
55
- const handle = await items.find((i) => i.id === 'csv')!.resolve()
55
+ const handle = await items
56
+ .find((i) => i.id === 'csv')!
57
+ .resolve({ id: null })
56
58
  expect(handle.filename).toBe('h.csv')
57
59
  expect(csvText).toBe(
58
60
  'bin_low,bin_high,series_1,series_2\n0,10,1,4\n10,20,2,5\n20,30,3,6',
@@ -66,7 +68,7 @@ describe('createHistogramDownloadConfig', () => {
66
68
  getTicks: () => ticks,
67
69
  seriesNames: ['lo', 'hi'],
68
70
  })
69
- await items.find((i) => i.id === 'csv')!.resolve()
71
+ await items.find((i) => i.id === 'csv')!.resolve({ id: null })
70
72
  expect(csvText.startsWith('bin_low,bin_high,lo,hi')).toBe(true)
71
73
  })
72
74
 
@@ -76,7 +78,7 @@ describe('createHistogramDownloadConfig', () => {
76
78
  getData: () => data,
77
79
  getTicks: () => [0],
78
80
  })
79
- await items.find((i) => i.id === 'csv')!.resolve()
81
+ await items.find((i) => i.id === 'csv')!.resolve({ id: null })
80
82
  expect(csvText).toBe('bin_low,bin_high,series_1,series_2')
81
83
  })
82
84
 
@@ -87,7 +89,7 @@ describe('createHistogramDownloadConfig', () => {
87
89
  getData: () => sparse,
88
90
  getTicks: () => ticks,
89
91
  })
90
- await items.find((i) => i.id === 'csv')!.resolve()
92
+ await items.find((i) => i.id === 'csv')!.resolve({ id: null })
91
93
  expect(csvText).toContain('0,10,1,0')
92
94
  expect(csvText).toContain('10,20,2,0')
93
95
  })
@@ -1,41 +1,29 @@
1
1
  import {
2
2
  buildCsvDownloadItem,
3
- buildPngDownloadItem,
3
+ withPngItem,
4
4
  type DownloadItem,
5
+ type WidgetDownloadArgs,
5
6
  } from '../actions/download'
6
7
  import type { HistogramWidgetData } from './types'
7
8
 
8
9
  /**
9
10
  * Download menu items for histograms. Always includes a CSV item with
10
11
  * `bin_low, bin_high, series_1_count, series_2_count, …` columns (one row
11
- * per bin). When `getCaptureEl` is supplied, prepends a PNG item that
12
+ * per bin). When `getCaptureEl` is given, prepends a PNG item that
12
13
  * rasterises the captured element via `html2canvas`.
13
14
  */
14
- export function createHistogramDownloadConfig(args: {
15
- filename: string
16
- getData: () => HistogramWidgetData
17
- getTicks: () => readonly number[]
18
- seriesNames?: readonly string[]
19
- getCaptureEl?: () => HTMLElement | null
20
- pngPixelRatio?: number
21
- pngBackgroundColor?: string | null
22
- }): DownloadItem[] {
23
- const items: DownloadItem[] = []
24
- if (args.getCaptureEl) {
25
- items.push(
26
- buildPngDownloadItem({
27
- filename: args.filename,
28
- getCaptureEl: args.getCaptureEl,
29
- pixelRatio: args.pngPixelRatio,
30
- backgroundColor: args.pngBackgroundColor,
31
- }),
32
- )
33
- }
34
- items.push(
15
+ export function createHistogramDownloadConfig(
16
+ args: WidgetDownloadArgs<HistogramWidgetData> & {
17
+ getTicks: () => readonly number[]
18
+ seriesNames?: readonly string[]
19
+ },
20
+ ): DownloadItem[] {
21
+ return withPngItem(
22
+ args,
35
23
  buildCsvDownloadItem({
36
24
  filename: args.filename,
37
- getRows: () => {
38
- const data = args.getData()
25
+ getRows: (ctx) => {
26
+ const data = args.getData(ctx)
39
27
  const ticks = args.getTicks()
40
28
  const seriesCount = data.length
41
29
  const header: unknown[] = ['bin_low', 'bin_high']
@@ -52,5 +40,4 @@ export function createHistogramDownloadConfig(args: {
52
40
  },
53
41
  }),
54
42
  )
55
- return items
56
43
  }
@@ -157,6 +157,7 @@ export type {
157
157
  RelativeDataProps,
158
158
  DownloadProps,
159
159
  DownloadItem,
160
+ DownloadContext,
160
161
  FullScreenTriggerProps,
161
162
  FullScreenSlotProps,
162
163
  LockSelectionProps,
@@ -46,7 +46,7 @@ describe('createMarkdownDownloadConfig', () => {
46
46
  filename: 'doc',
47
47
  getData: () => ({ content: '# title\n\nbody' }),
48
48
  })
49
- const handle = await items.find((i) => i.id === 'md')!.resolve()
49
+ const handle = await items.find((i) => i.id === 'md')!.resolve({ id: null })
50
50
  expect(handle.url).toBe('blob:mock')
51
51
  expect(handle.filename).toBe('doc.md')
52
52
  expect(mdText).toBe('# title\n\nbody')
@@ -60,7 +60,7 @@ describe('createMarkdownDownloadConfig', () => {
60
60
  // missing content field — exercise the `?? ''` branch
61
61
  getData: () => ({}) as { content: string },
62
62
  })
63
- await items.find((i) => i.id === 'md')!.resolve()
63
+ await items.find((i) => i.id === 'md')!.resolve({ id: null })
64
64
  expect(mdText).toBe('')
65
65
  })
66
66
  })