@carto/ps-react-ui 4.22.0 → 4.23.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 (103) hide show
  1. package/dist/actions-D7LB9DvT.js +1161 -0
  2. package/dist/actions-D7LB9DvT.js.map +1 -0
  3. package/dist/category-Ca5XDx0N.js +839 -0
  4. package/dist/category-Ca5XDx0N.js.map +1 -0
  5. package/dist/{download-C7he8mSh.js → download--wRRnf19.js} +81 -80
  6. package/dist/download--wRRnf19.js.map +1 -0
  7. package/dist/{echart-BnJYXH7H.js → echart-CgOQw9XB.js} +3 -2
  8. package/dist/echart-CgOQw9XB.js.map +1 -0
  9. package/dist/{formula-Cqj84ZGX.js → formula-DRuMIVo8.js} +6 -6
  10. package/dist/formula-DRuMIVo8.js.map +1 -0
  11. package/dist/{markdown-CV_KP-at.js → markdown-C3OX9jyy.js} +6 -6
  12. package/dist/markdown-C3OX9jyy.js.map +1 -0
  13. package/dist/{spread-C8T12Nfh.js → spread-BT07rCJw.js} +7 -7
  14. package/dist/spread-BT07rCJw.js.map +1 -0
  15. package/dist/{table-CNQijNNB.js → table-CRG9eOs9.js} +3 -3
  16. package/dist/table-CRG9eOs9.js.map +1 -0
  17. package/dist/types/widgets-v2/actions/download/csv-item.d.ts +3 -3
  18. package/dist/types/widgets-v2/actions/download/index.d.ts +1 -1
  19. package/dist/types/widgets-v2/actions/download/png-item.d.ts +2 -2
  20. package/dist/types/widgets-v2/actions/download/types.d.ts +12 -1
  21. package/dist/types/widgets-v2/actions/fullscreen/fullscreen.d.ts +20 -13
  22. package/dist/types/widgets-v2/actions/index.d.ts +1 -1
  23. package/dist/types/widgets-v2/bar/download.d.ts +3 -3
  24. package/dist/types/widgets-v2/category/category-ui.d.ts +9 -1
  25. package/dist/types/widgets-v2/category/download.d.ts +3 -3
  26. package/dist/types/widgets-v2/echart/style.d.ts +1 -0
  27. package/dist/types/widgets-v2/formula/download.d.ts +3 -3
  28. package/dist/types/widgets-v2/histogram/download.d.ts +3 -3
  29. package/dist/types/widgets-v2/index.d.ts +1 -1
  30. package/dist/types/widgets-v2/markdown/download.d.ts +3 -3
  31. package/dist/types/widgets-v2/pie/download.d.ts +3 -3
  32. package/dist/types/widgets-v2/scatterplot/download.d.ts +3 -3
  33. package/dist/types/widgets-v2/spread/download.d.ts +3 -3
  34. package/dist/types/widgets-v2/table/download.d.ts +3 -3
  35. package/dist/types/widgets-v2/timeseries/download.d.ts +3 -3
  36. package/dist/types/widgets-v2/toolbox/toolbox.d.ts +1 -1
  37. package/dist/widgets-v2/actions.js +2 -2
  38. package/dist/widgets-v2/bar.js +7 -7
  39. package/dist/widgets-v2/bar.js.map +1 -1
  40. package/dist/widgets-v2/category.js +1 -1
  41. package/dist/widgets-v2/echart.js +1 -1
  42. package/dist/widgets-v2/formula.js +1 -1
  43. package/dist/widgets-v2/histogram.js +10 -10
  44. package/dist/widgets-v2/histogram.js.map +1 -1
  45. package/dist/widgets-v2/markdown.js +1 -1
  46. package/dist/widgets-v2/pie.js +10 -10
  47. package/dist/widgets-v2/pie.js.map +1 -1
  48. package/dist/widgets-v2/scatterplot.js +9 -9
  49. package/dist/widgets-v2/scatterplot.js.map +1 -1
  50. package/dist/widgets-v2/spread.js +1 -1
  51. package/dist/widgets-v2/table.js +1 -1
  52. package/dist/widgets-v2/timeseries.js +12 -12
  53. package/dist/widgets-v2/timeseries.js.map +1 -1
  54. package/dist/widgets-v2.js +274 -333
  55. package/dist/widgets-v2.js.map +1 -1
  56. package/package.json +3 -3
  57. package/src/widgets-v2/actions/download/csv-item.test.tsx +3 -3
  58. package/src/widgets-v2/actions/download/csv-item.tsx +6 -6
  59. package/src/widgets-v2/actions/download/download.tsx +5 -3
  60. package/src/widgets-v2/actions/download/index.ts +1 -1
  61. package/src/widgets-v2/actions/download/png-item.test.tsx +5 -3
  62. package/src/widgets-v2/actions/download/png-item.tsx +4 -4
  63. package/src/widgets-v2/actions/download/types.ts +15 -1
  64. package/src/widgets-v2/actions/fullscreen/fullscreen-store.test.tsx +155 -0
  65. package/src/widgets-v2/actions/fullscreen/fullscreen.test.tsx +39 -41
  66. package/src/widgets-v2/actions/fullscreen/fullscreen.tsx +211 -96
  67. package/src/widgets-v2/actions/index.ts +1 -0
  68. package/src/widgets-v2/actions/searcher/searcher.tsx +17 -2
  69. package/src/widgets-v2/bar/download.test.tsx +5 -3
  70. package/src/widgets-v2/bar/download.tsx +5 -4
  71. package/src/widgets-v2/category/category-ui.tsx +20 -9
  72. package/src/widgets-v2/category/category.tsx +37 -2
  73. package/src/widgets-v2/category/download.test.ts +4 -2
  74. package/src/widgets-v2/category/download.ts +5 -4
  75. package/src/widgets-v2/echart/style.ts +6 -0
  76. package/src/widgets-v2/formula/download.test.tsx +3 -1
  77. package/src/widgets-v2/formula/download.tsx +5 -4
  78. package/src/widgets-v2/histogram/download.test.ts +6 -4
  79. package/src/widgets-v2/histogram/download.ts +5 -4
  80. package/src/widgets-v2/index.ts +1 -0
  81. package/src/widgets-v2/markdown/download.test.ts +2 -2
  82. package/src/widgets-v2/markdown/download.tsx +5 -4
  83. package/src/widgets-v2/pie/download.test.ts +7 -3
  84. package/src/widgets-v2/pie/download.ts +5 -4
  85. package/src/widgets-v2/scatterplot/download.test.ts +4 -2
  86. package/src/widgets-v2/scatterplot/download.ts +5 -4
  87. package/src/widgets-v2/spread/download.test.ts +3 -1
  88. package/src/widgets-v2/spread/download.ts +5 -4
  89. package/src/widgets-v2/table/download.test.ts +5 -3
  90. package/src/widgets-v2/table/download.ts +4 -3
  91. package/src/widgets-v2/timeseries/download.test.ts +6 -4
  92. package/src/widgets-v2/timeseries/download.ts +5 -4
  93. package/src/widgets-v2/toolbox/toolbox.tsx +1 -1
  94. package/dist/actions-iN1jwyxA.js +0 -1024
  95. package/dist/actions-iN1jwyxA.js.map +0 -1
  96. package/dist/category-DZt6yiST.js +0 -826
  97. package/dist/category-DZt6yiST.js.map +0 -1
  98. package/dist/download-C7he8mSh.js.map +0 -1
  99. package/dist/echart-BnJYXH7H.js.map +0 -1
  100. package/dist/formula-Cqj84ZGX.js.map +0 -1
  101. package/dist/markdown-CV_KP-at.js.map +0 -1
  102. package/dist/spread-C8T12Nfh.js.map +0 -1
  103. package/dist/table-CNQijNNB.js.map +0 -1
@@ -1,11 +1,12 @@
1
+ import type { ECharts } from 'echarts'
1
2
  import {
2
3
  useCallback,
3
4
  useLayoutEffect,
5
+ useRef,
4
6
  useState,
5
7
  type ComponentType,
6
8
  type ReactNode,
7
9
  } from 'react'
8
- import { createPortal } from 'react-dom'
9
10
  import {
10
11
  Box,
11
12
  Dialog,
@@ -24,7 +25,19 @@ import { Fullscreen as FullscreenIcon } from '@mui/icons-material'
24
25
  import { FullscreenExit as FullscreenExitIcon } from '@mui/icons-material'
25
26
  import { Close as CloseIcon } from '@mui/icons-material'
26
27
  import { Tooltip } from '../../../components'
27
- import { getWidgetStore, useWidget, useWidgetId } from '../../stores'
28
+ import { Provider } from '../../provider/widget-provider'
29
+ import {
30
+ createWidgetStore,
31
+ getEchartInstance,
32
+ getWidgetStore,
33
+ hasWidgetStore,
34
+ setWidgetStoreEntry,
35
+ subscribeEchartInstance,
36
+ useWidget,
37
+ useWidgetId,
38
+ useWidgetShallow,
39
+ type WidgetState,
40
+ } from '../../stores'
28
41
  import { DEFAULT_FULLSCREEN_LABELS, type FullScreenLabels } from './labels'
29
42
  import type { FullScreenWidgetState } from './types'
30
43
  import { styles } from './style'
@@ -38,11 +51,18 @@ export interface FullScreenTriggerProps {
38
51
 
39
52
  export interface FullScreenSlotProps {
40
53
  /**
41
- * Render-prop body. Receives the live `isFullScreen` flag so the consumer
42
- * can branch per-mode props (e.g., `Widget.Toolbox visibleCount`) without
43
- * duplicating the JSX.
54
+ * The widget body. Rendered twice, identically: in the card, and again in
55
+ * the dialog while it's open. The components inside don't know which copy
56
+ * they are; the dialog's just runs on a widget store of its own — a copy
57
+ * of the card's taken on open, handed back to the card on close — so read
58
+ * the widget id with `useWidgetId()` inside the body rather than closing
59
+ * over the card's `id`.
60
+ *
61
+ * @deprecated The function form `(isFullScreen) => ReactNode` still works
62
+ * (`false` for the card, `true` for the dialog copy) but will be removed
63
+ * in the next major: pass the body as plain children.
44
64
  */
45
- children: (isFullScreen: boolean) => ReactNode
65
+ children: ReactNode | ((isFullScreen: boolean) => ReactNode)
46
66
  /** Header text rendered above the body when the modal is open. */
47
67
  title?: ReactNode
48
68
  /**
@@ -113,15 +133,42 @@ function FullScreenTrigger({
113
133
  )
114
134
  }
115
135
 
136
+ /** Id of the widget store the dialog copy runs on. */
137
+ export function fullScreenStoreId(id: string): string {
138
+ return `${id}::fullscreen`
139
+ }
140
+
141
+ // Fields a copy doesn't carry between the card's store and the dialog's:
142
+ // what the consumer feeds through Provider props (kept live on both), what
143
+ // the pipeline derives, the transforms (functions each copy's actions
144
+ // register in their own store) and the open flag itself.
145
+ const NOT_COPIED = new Set<string>([
146
+ 'rawData',
147
+ 'data',
148
+ 'dataTransforms',
149
+ 'configTransforms',
150
+ 'isLoading',
151
+ 'isFetching',
152
+ 'error',
153
+ 'rawFormatter',
154
+ 'labelFormatter',
155
+ 'isFullScreen',
156
+ ])
157
+
158
+ /** The UI state a copy carries: toggles, search text, sort, page… */
159
+ function copiedState(state: WidgetState): Partial<WidgetState> {
160
+ return Object.fromEntries(
161
+ Object.entries(state).filter(([key]) => !NOT_COPIED.has(key)),
162
+ ) as Partial<WidgetState>
163
+ }
164
+
116
165
  /**
117
- * Body wrapper. Renders its render-prop children once, via a single
118
- * `createPortal` call whose container is a stable `<div>` we hold as a ref.
119
- * That div is moved imperatively between an inline placeholder and the
120
- * `<DialogContent>` of an MUI `<Dialog keepMounted>`. Because both the
121
- * React parent (the portal call site) AND the portal container are
122
- * identical across the toggle, the subtree keeps its fiber identity and
123
- * ECharts (or any other heavy child) stays mounted while only its DOM
124
- * parent changes.
166
+ * Body wrapper. The card keeps its copy of the body while the dialog is
167
+ * open, and the dialog mounts a second one on a store of its own: a copy
168
+ * of the card's UI state taken on open, and handed back to the card when
169
+ * the dialog closes. While open the two don't affect each other. MUI
170
+ * mounts the dialog copy on open and unmounts it after the close
171
+ * transition, which drops its store.
125
172
  */
126
173
  function FullScreenSlot({
127
174
  children,
@@ -144,59 +191,34 @@ function FullScreenSlot({
144
191
  // margins — on a screen that small, fullscreen should mean fullscreen.
145
192
  const theme = useTheme()
146
193
  const fullBleed = useMediaQuery(theme.breakpoints.down('sm'))
147
- const [inlineEl, setInlineEl] = useState<HTMLElement | null>(null)
148
- const [dialogEl, setDialogEl] = useState<HTMLElement | null>(null)
149
- // Stable, never-changing portal container created once per slot. Held in
150
- // state (not a ref) so the React Compiler is happy reading it during
151
- // render — it's immutable from React's perspective, only its DOM parent
152
- // changes via `appendChild`. Switching `createPortal`'s `container`
153
- // argument makes React unmount and remount the children, defeating the
154
- // single-mount goal; keeping the container identical and moving the div
155
- // imperatively avoids that.
156
- const [portalDiv] = useState<HTMLDivElement | null>(() => {
157
- if (typeof document === 'undefined') return null
158
- const div = document.createElement('div')
159
- // `display: contents` keeps the wrapper transparent in flow so it
160
- // doesn't introduce a stray block when nested inside the inline slot.
161
- div.style.display = 'contents'
162
- return div
163
- })
164
-
165
194
  const handleClose = useCallback(() => {
166
195
  getWidgetStore(id).setState({
167
196
  isFullScreen: false,
168
197
  } as Partial<FullScreenWidgetState>)
169
198
  }, [id])
170
199
 
171
- // Reparent the portal div into the active host whenever `open` (or either
172
- // host) changes. `useLayoutEffect` runs synchronously after commit (after
173
- // refs have been written), so the dialog ref is already live when we read
174
- // it.
175
- //
176
- // Anti-blink: pin a `min-height` on the inline shell that's always live —
177
- // not just while fullscreen is open. We re-measure whenever the body is
178
- // inline (open=false) so the lock tracks data-driven height changes
179
- // (Formula adding rows, Table growing, ECharts re-init…) and survives
180
- // every fullscreen toggle. Briefly clearing minHeight before the measure
181
- // lets the lock shrink as well as grow; both operations are synchronous
182
- // within useLayoutEffect, so the browser never paints the intermediate
183
- // "no lock" frame.
200
+ // Closing hands the dialog's state back to the card. Keyed on the flag,
201
+ // not on the close button, so every way out (Escape, backdrop, a Trigger)
202
+ // counts. The dialog copy is still mounted here — it unmounts after the
203
+ // transition — so its store still exists.
204
+ const wasOpen = useRef(open)
184
205
  useLayoutEffect(() => {
185
- if (!portalDiv || !inlineEl) return
186
- const host = open ? dialogEl : inlineEl
187
- if (host && portalDiv.parentNode !== host) {
188
- host.appendChild(portalDiv)
206
+ const dialogId = fullScreenStoreId(id)
207
+ if (wasOpen.current && !open && hasWidgetStore(dialogId)) {
208
+ getWidgetStore(id).setState(
209
+ copiedState(getWidgetStore(dialogId).getState()),
210
+ )
189
211
  }
190
- if (!open) refreshInlineHeightLock(inlineEl)
191
- }, [open, inlineEl, dialogEl, portalDiv])
212
+ wasOpen.current = open
213
+ }, [id, open])
192
214
 
193
215
  const titleId = `widget-fullscreen-title-${id}`
194
- const rendered = children(open)
216
+ const render = (inDialog: boolean) =>
217
+ typeof children === 'function' ? children(inDialog) : children
195
218
 
196
219
  return (
197
220
  <>
198
- <Box ref={setInlineEl} sx={styles.inlineSlot} />
199
- {portalDiv ? createPortal(rendered, portalDiv) : null}
221
+ <Box sx={styles.inlineSlot}>{render(false)}</Box>
200
222
  <Dialog
201
223
  open={open}
202
224
  onClose={handleClose}
@@ -212,41 +234,146 @@ function FullScreenSlot({
212
234
  // default modal layer the menu portals after the dialog and wins on
213
235
  // DOM order, and the tooltip wins on z-index.
214
236
  //
215
- // This used to sit at `tooltip + 1` to cover a Toolbox popper left
216
- // open when fullscreen opened. That's the Toolbox's own job and it
217
- // already does it: `visibleCount` flips to `undefined` in fullscreen,
218
- // overflow disappears and the popper unmounts. A consumer who keeps a
219
- // numeric `visibleCount` in fullscreen keeps its popper above the
220
- // dialog — which is the correct layer for a popover opened from
221
- // inside it anyway.
237
+ // A Toolbox popover opened from inside the dialog portals after it
238
+ // too, so it stays above the dialog — the right layer for it.
222
239
  >
223
- <DialogTitle id={titleId} component='div' sx={styles.dialogTitle}>
224
- <Typography
225
- variant='subtitle1'
226
- component='span'
227
- sx={styles.titleText}
228
- >
229
- {title}
230
- </Typography>
231
- <Box sx={styles.dialogActions}>
232
- {actions}
233
- <Tooltip title={_labels.close}>
234
- <IconButton
235
- size='medium'
236
- aria-label={_labels.close}
237
- onClick={handleClose}
238
- >
239
- <CloseSvg fontSize='small' {...iconProps} />
240
- </IconButton>
241
- </Tooltip>
242
- </Box>
243
- </DialogTitle>
244
- <DialogContent ref={setDialogEl} sx={styles.dialogContent} />
240
+ <DialogStore sourceId={id}>
241
+ <DialogTitle id={titleId} component='div' sx={styles.dialogTitle}>
242
+ <Typography
243
+ variant='subtitle1'
244
+ component='span'
245
+ sx={styles.titleText}
246
+ >
247
+ {title}
248
+ </Typography>
249
+ <Box sx={styles.dialogActions}>
250
+ {actions}
251
+ <Tooltip title={_labels.close}>
252
+ <IconButton
253
+ size='medium'
254
+ aria-label={_labels.close}
255
+ onClick={handleClose}
256
+ >
257
+ <CloseSvg fontSize='small' {...iconProps} />
258
+ </IconButton>
259
+ </Tooltip>
260
+ </Box>
261
+ </DialogTitle>
262
+ <DialogContent sx={styles.dialogContent}>
263
+ {render(true)}
264
+ </DialogContent>
265
+ </DialogStore>
245
266
  </Dialog>
246
267
  </>
247
268
  )
248
269
  }
249
270
 
271
+ /**
272
+ * Gives the dialog copy (header actions included) its own widget store,
273
+ * seeded from the card's when the dialog mounts, and keeps the consumer's
274
+ * props (data, loading, error, formatters) flowing into it from the card
275
+ * while it's open. Does the same for the chart, if the widget has one: the
276
+ * dialog's starts from the card's and hands its state back on close.
277
+ */
278
+ function DialogStore({
279
+ sourceId,
280
+ children,
281
+ }: {
282
+ sourceId: string
283
+ children: ReactNode
284
+ }) {
285
+ const dialogId = fullScreenStoreId(sourceId)
286
+ const live = useWidgetShallow(sourceId, (s) => ({
287
+ rawData: s.rawData,
288
+ isLoading: s.isLoading,
289
+ isFetching: s.isFetching,
290
+ error: s.error,
291
+ formatter: s.rawFormatter,
292
+ labelFormatter: s.labelFormatter,
293
+ }))
294
+ // Runs before the Provider below looks the id up, so it adopts this
295
+ // store instead of creating an empty one.
296
+ useState(() => {
297
+ const store = createWidgetStore(dialogId, {
298
+ data: live.rawData,
299
+ isLoading: live.isLoading,
300
+ isFetching: live.isFetching,
301
+ error: live.error,
302
+ formatter: live.formatter,
303
+ labelFormatter: live.labelFormatter,
304
+ })
305
+ store.setState(copiedState(getWidgetStore(sourceId).getState()))
306
+ setWidgetStoreEntry(dialogId, store)
307
+ return store
308
+ })
309
+
310
+ // A layout effect on purpose. It subscribes before the dialog chart's
311
+ // (passive) init effect publishes it, and its cleanup runs before that
312
+ // chart's (passive) dispose — so the chart is still alive to copy from.
313
+ useLayoutEffect(() => {
314
+ let dialogChart: ECharts | null = null
315
+ const unsubscribe = subscribeEchartInstance(dialogId, (chart) => {
316
+ // Published from inside the chart's init, before its first
317
+ // setOption, so the dialog's own option merges on top of the copy.
318
+ const card = liveChart(sourceId)
319
+ if (chart && card) copyChartState(card, chart)
320
+ dialogChart = chart
321
+ })
322
+ return () => {
323
+ unsubscribe()
324
+ const card = liveChart(sourceId)
325
+ if (card && dialogChart && !dialogChart.isDisposed()) {
326
+ copyChartState(dialogChart, card)
327
+ }
328
+ }
329
+ }, [dialogId, sourceId])
330
+
331
+ return (
332
+ <Provider
333
+ id={dialogId}
334
+ data={live.rawData}
335
+ isLoading={live.isLoading}
336
+ isFetching={live.isFetching}
337
+ error={live.error}
338
+ formatter={live.formatter}
339
+ labelFormatter={live.labelFormatter}
340
+ >
341
+ {children}
342
+ </Provider>
343
+ )
344
+ }
345
+
346
+ function liveChart(id: string): ECharts | null {
347
+ const chart = getEchartInstance(id)
348
+ return chart && !chart.isDisposed() ? chart : null
349
+ }
350
+
351
+ /**
352
+ * Put on `chart` what `peer` is showing: the legend selection, the dataZoom
353
+ * window and any drawn brush areas, which live in the ECharts instance
354
+ * rather than the widget store.
355
+ */
356
+ function copyChartState(peer: ECharts, chart: ECharts): void {
357
+ chart.setOption(peer.getOption())
358
+ // The pointer was over `peer`, not `chart`: drop the tooltip and axis
359
+ // pointer that came along, or they'd stay drawn until the next hover.
360
+ chart.dispatchAction({ type: 'hideTip' })
361
+ chart.dispatchAction({ type: 'updateAxisPointer', currTrigger: 'leave' })
362
+ // Brush areas sit on the brush model, not in `getOption()`. `getModel` is
363
+ // private in the typings but stable across 5.x.
364
+ // ponytail: private API; if it goes away, track areas from the `brush` event instead.
365
+ const areas = (
366
+ peer as unknown as {
367
+ getModel?: () => {
368
+ getComponent: (type: string) => { areas?: unknown[] } | undefined
369
+ }
370
+ }
371
+ )
372
+ .getModel?.()
373
+ .getComponent('brush')?.areas
374
+ if (areas?.length) chart.dispatchAction({ type: 'brush', areas })
375
+ }
376
+
250
377
  /**
251
378
  * Merge a caller's `paperSx` over the default paper.
252
379
  *
@@ -269,18 +396,6 @@ function resolvePaperSx(paperSx: SxProps<Theme> | undefined): SxProps<Theme> {
269
396
  }
270
397
  }
271
398
 
272
- /**
273
- * Briefly clear the inline shell's `min-height`, measure its natural
274
- * height, then re-pin. Lives outside the component so the React Compiler
275
- * doesn't see the DOM mutation as an attempt to modify a `useState` value
276
- * — `el` here is just a parameter, not a tracked store value.
277
- */
278
- function refreshInlineHeightLock(el: HTMLElement): void {
279
- el.style.minHeight = ''
280
- const h = el.getBoundingClientRect().height
281
- if (h > 0) el.style.minHeight = `${h}px`
282
- }
283
-
284
399
  /**
285
400
  * Namespace export. Consumers compose with:
286
401
  *
@@ -290,7 +405,7 @@ function refreshInlineHeightLock(el: HTMLElement): void {
290
405
  * </Widget.Actions>
291
406
  * <Widget.Content>
292
407
  * <Widget.FullScreen.Slot title={title} actions={<Widget.Download items={items} />}>
293
- * {(isFullScreen) => <Body visibleCount={isFullScreen ? undefined : 2} />}
408
+ * <Body />
294
409
  * </Widget.FullScreen.Slot>
295
410
  * </Widget.Content>
296
411
  * ```
@@ -62,6 +62,7 @@ export {
62
62
  DOWNLOAD_ITEM_IDS,
63
63
  type DownloadProps,
64
64
  type DownloadItem,
65
+ type DownloadContext,
65
66
  type DownloadItemId,
66
67
  type DownloadHandle,
67
68
  type DownloadDOMToPNGOptions,
@@ -37,10 +37,23 @@ export function Searcher({
37
37
  const [local, setLocal] = useState<string>(
38
38
  () =>
39
39
  (getWidgetStore(id).getState().transformStates.searcher?.searchText as
40
- | string
41
- | undefined) ?? '',
40
+ string | undefined) ?? '',
42
41
  )
43
42
 
43
+ // Follow text written by another Searcher of the same widget (the card
44
+ // and the fullscreen copy each render one). Skipped while this input has
45
+ // focus: its own debounced write lands late, and adopting it would drop
46
+ // whatever was typed since.
47
+ const [focused, setFocused] = useState(false)
48
+ const stored =
49
+ (useWidget(id, (s) => s.transformStates.searcher?.searchText) as
50
+ string | undefined) ?? ''
51
+ const [prevStored, setPrevStored] = useState(stored)
52
+ if (stored !== prevStored) {
53
+ setPrevStored(stored)
54
+ if (!focused) setLocal(stored)
55
+ }
56
+
44
57
  const debouncedWrite = useMemo(
45
58
  () =>
46
59
  debounce(
@@ -79,6 +92,8 @@ export function Searcher({
79
92
  placeholder={resolved.placeholder}
80
93
  value={local}
81
94
  onChange={handleSearchTextChange}
95
+ onFocus={() => setFocused(true)}
96
+ onBlur={() => setFocused(false)}
82
97
  // The Searcher only mounts when the user explicitly toggles the
83
98
  // SearcherToggle action — focusing the input on mount matches user
84
99
  // 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,20 +1,21 @@
1
1
  import {
2
2
  buildCsvDownloadItem,
3
3
  buildPngDownloadItem,
4
+ type DownloadContext,
4
5
  type DownloadItem,
5
6
  } from '../actions/download'
6
7
  import type { BarWidgetData } from './types'
7
8
 
8
9
  export interface BarDownloadConfigArgs {
9
10
  filename: string
10
- getData: () => BarWidgetData
11
+ getData: (ctx: DownloadContext) => BarWidgetData
11
12
  /**
12
13
  * Optional getter for the widget's capture element (registered by
13
14
  * `Widget.State` on its success path). When provided, a PNG item is
14
15
  * prepended to the returned list. Wire it to
15
16
  * `() => getCaptureEl(id)`.
16
17
  */
17
- getCaptureEl?: () => HTMLElement | null
18
+ getCaptureEl?: (ctx: DownloadContext) => HTMLElement | null
18
19
  /** PNG `pixelRatio` (default 2). */
19
20
  pngPixelRatio?: number
20
21
  /** PNG `backgroundColor` (default transparent). */
@@ -44,8 +45,8 @@ export function createBarDownloadConfig(
44
45
  items.push(
45
46
  buildCsvDownloadItem({
46
47
  filename: args.filename,
47
- getRows: () => {
48
- const data = args.getData()
48
+ getRows: (ctx) => {
49
+ const data = args.getData(ctx)
49
50
  const rows: unknown[][] = []
50
51
  for (const [i, series] of data.entries()) {
51
52
  if (i > 0) rows.push([])
@@ -96,6 +96,14 @@ export interface CategoryUIProps {
96
96
  * No-op for single-series data — same convention as the legend.
97
97
  */
98
98
  stacked?: boolean
99
+ /**
100
+ * Series indices hidden via the legend. Leave both this and
101
+ * `onHiddenSeriesChange` unset and CategoryUI keeps the set itself; pass
102
+ * them to own it — `Category` does, so the card and the fullscreen copy
103
+ * share it through the widget store.
104
+ */
105
+ hiddenSeries?: ReadonlySet<number>
106
+ onHiddenSeriesChange?: (next: ReadonlySet<number>) => void
99
107
  }
100
108
 
101
109
  interface GroupedRow {
@@ -167,6 +175,8 @@ export function CategoryUI({
167
175
  maxOverride,
168
176
  size = 'small',
169
177
  stacked = false,
178
+ hiddenSeries: hiddenSeriesProp,
179
+ onHiddenSeriesChange,
170
180
  }: CategoryUIProps) {
171
181
  const theme = useTheme()
172
182
  const fmt = formatter ?? ((n: number) => String(n))
@@ -286,9 +296,10 @@ export function CategoryUI({
286
296
  // selection, unrelated). Never allowed to reach "every series hidden":
287
297
  // toggling the last visible one instead resets to "all visible" rather
288
298
  // than leaving the widget blank.
289
- const [hiddenSeries, setHiddenSeries] = useState<ReadonlySet<number>>(
299
+ const [localHidden, setLocalHidden] = useState<ReadonlySet<number>>(
290
300
  () => new Set(),
291
301
  )
302
+ const hiddenSeries = hiddenSeriesProp ?? localHidden
292
303
  // A reload with a different series breakdown invalidates hidden indices —
293
304
  // index 2 may now point at an entirely different series. Reset rather
294
305
  // than keep hiding whatever now occupies a stale index. Adjusted directly
@@ -303,7 +314,7 @@ export function CategoryUI({
303
314
  const [prevSeriesKey, setPrevSeriesKey] = useState(seriesKey)
304
315
  if (seriesKey !== prevSeriesKey) {
305
316
  setPrevSeriesKey(seriesKey)
306
- setHiddenSeries(new Set())
317
+ setLocalHidden(new Set())
307
318
  }
308
319
  // Real number of data series — NOT `series?.length`, which is legend
309
320
  // metadata and may legitimately be shorter than the actual series count
@@ -313,14 +324,14 @@ export function CategoryUI({
313
324
  const seriesCount = Math.max(data.length, 1)
314
325
  const toggleSeries = useCallback(
315
326
  (index: number) => {
316
- setHiddenSeries((prev) => {
317
- const next = new Set(prev)
318
- if (next.has(index)) next.delete(index)
319
- else next.add(index)
320
- return next.size >= seriesCount ? new Set() : next
321
- })
327
+ const next = new Set(hiddenSeries)
328
+ if (next.has(index)) next.delete(index)
329
+ else next.add(index)
330
+ ;(onHiddenSeriesChange ?? setLocalHidden)(
331
+ next.size >= seriesCount ? new Set() : next,
332
+ )
322
333
  },
323
- [seriesCount],
334
+ [hiddenSeries, onHiddenSeriesChange, seriesCount],
324
335
  )
325
336
 
326
337
  if (grouped.length === 0) return null
@@ -1,5 +1,11 @@
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'
4
10
  import type {
5
11
  CategoryKey,
@@ -23,6 +29,19 @@ const categorySelector = (s: WidgetState): CategorySlice => ({
23
29
  labelFormatter: s.labelFormatter,
24
30
  })
25
31
 
32
+ /**
33
+ * Legend-hidden series, kept on the widget store so every mounted Category
34
+ * of the widget — the card and its fullscreen copy — shows the same set.
35
+ * Tagged with the series breakdown it was made for: after a reload with
36
+ * different series the indices point elsewhere, so a stale set reads as
37
+ * empty instead of being reset with a write.
38
+ */
39
+ interface CategoryWidgetState extends WidgetState {
40
+ categoryHiddenSeries?: { key: string; indices: ReadonlySet<number> }
41
+ }
42
+
43
+ const NO_HIDDEN: ReadonlySet<number> = new Set()
44
+
26
45
  function maxFromCategoryData(d: CategoryWidgetData | undefined): number {
27
46
  if (!d) return 0
28
47
  let m = 0
@@ -111,6 +130,20 @@ export function Category({
111
130
  )
112
131
  const effectiveMaxOverride =
113
132
  maxOverride ?? (naturalMax > 0 ? naturalMax : undefined)
133
+
134
+ const seriesKey = JSON.stringify(series?.map((s) => s.name) ?? [])
135
+ const stored = useWidget<
136
+ CategoryWidgetState['categoryHiddenSeries'],
137
+ CategoryWidgetState
138
+ >(id, (s) => s.categoryHiddenSeries)
139
+ const hiddenSeries = stored?.key === seriesKey ? stored.indices : NO_HIDDEN
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
  }
@@ -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
  })