@carto/ps-react-ui 5.0.0 → 5.2.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 (109) hide show
  1. package/dist/actions-CF8RNKd6.js +1161 -0
  2. package/dist/actions-CF8RNKd6.js.map +1 -0
  3. package/dist/category-D01ruG-l.js +842 -0
  4. package/dist/category-D01ruG-l.js.map +1 -0
  5. package/dist/{download-DdaK3qgC.js → download-DGgFOaXj.js} +81 -80
  6. package/dist/download-DGgFOaXj.js.map +1 -0
  7. package/dist/{echart-DeBgdOtX.js → echart-CwzbPElc.js} +29 -29
  8. package/dist/echart-CwzbPElc.js.map +1 -0
  9. package/dist/{formula-noJzQwKo.js → formula-BIxJr1P3.js} +6 -6
  10. package/dist/{formula-noJzQwKo.js.map → formula-BIxJr1P3.js.map} +1 -1
  11. package/dist/{markdown-2dfL6Cmb.js → markdown-oo9mnJun.js} +6 -6
  12. package/dist/{markdown-2dfL6Cmb.js.map → markdown-oo9mnJun.js.map} +1 -1
  13. package/dist/{spread-Ccpg9fi0.js → spread-CgCTKPQD.js} +7 -7
  14. package/dist/{spread-Ccpg9fi0.js.map → spread-CgCTKPQD.js.map} +1 -1
  15. package/dist/{table-Bx9Uj3U0.js → table-lb5momun.js} +3 -3
  16. package/dist/{table-Bx9Uj3U0.js.map → table-lb5momun.js.map} +1 -1
  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 +42 -16
  22. package/dist/types/widgets-v2/actions/fullscreen/style.d.ts +9 -0
  23. package/dist/types/widgets-v2/actions/index.d.ts +1 -1
  24. package/dist/types/widgets-v2/bar/download.d.ts +3 -3
  25. package/dist/types/widgets-v2/category/category-ui.d.ts +9 -1
  26. package/dist/types/widgets-v2/category/download.d.ts +3 -3
  27. package/dist/types/widgets-v2/echart/echart-ui.d.ts +2 -2
  28. package/dist/types/widgets-v2/echart/echart.d.ts +9 -1
  29. package/dist/types/widgets-v2/echart/style.d.ts +2 -0
  30. package/dist/types/widgets-v2/formula/download.d.ts +3 -3
  31. package/dist/types/widgets-v2/histogram/download.d.ts +3 -3
  32. package/dist/types/widgets-v2/index.d.ts +2 -2
  33. package/dist/types/widgets-v2/markdown/download.d.ts +3 -3
  34. package/dist/types/widgets-v2/pie/download.d.ts +3 -3
  35. package/dist/types/widgets-v2/scatterplot/download.d.ts +3 -3
  36. package/dist/types/widgets-v2/spread/download.d.ts +3 -3
  37. package/dist/types/widgets-v2/table/download.d.ts +3 -3
  38. package/dist/types/widgets-v2/timeseries/download.d.ts +3 -3
  39. package/dist/types/widgets-v2/toolbox/toolbox.d.ts +1 -1
  40. package/dist/widgets-v2/actions.js +2 -2
  41. package/dist/widgets-v2/bar.js +7 -7
  42. package/dist/widgets-v2/bar.js.map +1 -1
  43. package/dist/widgets-v2/category.js +1 -1
  44. package/dist/widgets-v2/echart.js +1 -1
  45. package/dist/widgets-v2/formula.js +1 -1
  46. package/dist/widgets-v2/histogram.js +10 -10
  47. package/dist/widgets-v2/histogram.js.map +1 -1
  48. package/dist/widgets-v2/markdown.js +1 -1
  49. package/dist/widgets-v2/pie.js +10 -11
  50. package/dist/widgets-v2/pie.js.map +1 -1
  51. package/dist/widgets-v2/scatterplot.js +9 -9
  52. package/dist/widgets-v2/scatterplot.js.map +1 -1
  53. package/dist/widgets-v2/spread.js +1 -1
  54. package/dist/widgets-v2/table.js +1 -1
  55. package/dist/widgets-v2/timeseries.js +12 -12
  56. package/dist/widgets-v2/timeseries.js.map +1 -1
  57. package/dist/widgets-v2.js +274 -333
  58. package/dist/widgets-v2.js.map +1 -1
  59. package/package.json +1 -1
  60. package/src/widgets-v2/actions/download/csv-item.test.tsx +3 -3
  61. package/src/widgets-v2/actions/download/csv-item.tsx +6 -6
  62. package/src/widgets-v2/actions/download/download.tsx +5 -3
  63. package/src/widgets-v2/actions/download/index.ts +1 -1
  64. package/src/widgets-v2/actions/download/png-item.test.tsx +5 -3
  65. package/src/widgets-v2/actions/download/png-item.tsx +4 -4
  66. package/src/widgets-v2/actions/download/types.ts +15 -1
  67. package/src/widgets-v2/actions/fullscreen/fullscreen-store.test.tsx +155 -0
  68. package/src/widgets-v2/actions/fullscreen/fullscreen.test.tsx +93 -33
  69. package/src/widgets-v2/actions/fullscreen/fullscreen.tsx +272 -86
  70. package/src/widgets-v2/actions/fullscreen/style.ts +31 -4
  71. package/src/widgets-v2/actions/index.ts +1 -0
  72. package/src/widgets-v2/actions/searcher/searcher.tsx +16 -0
  73. package/src/widgets-v2/bar/download.test.tsx +5 -3
  74. package/src/widgets-v2/bar/download.tsx +5 -4
  75. package/src/widgets-v2/category/category-ui.tsx +24 -13
  76. package/src/widgets-v2/category/category.tsx +37 -2
  77. package/src/widgets-v2/category/download.test.ts +4 -2
  78. package/src/widgets-v2/category/download.ts +5 -4
  79. package/src/widgets-v2/echart/echart-ui.test.tsx +6 -3
  80. package/src/widgets-v2/echart/echart-ui.tsx +6 -2
  81. package/src/widgets-v2/echart/echart.test.tsx +1 -1
  82. package/src/widgets-v2/echart/echart.tsx +10 -0
  83. package/src/widgets-v2/echart/style.ts +16 -1
  84. package/src/widgets-v2/formula/download.test.tsx +3 -1
  85. package/src/widgets-v2/formula/download.tsx +5 -4
  86. package/src/widgets-v2/histogram/download.test.ts +6 -4
  87. package/src/widgets-v2/histogram/download.ts +5 -4
  88. package/src/widgets-v2/index.ts +1 -0
  89. package/src/widgets-v2/markdown/download.test.ts +2 -2
  90. package/src/widgets-v2/markdown/download.tsx +5 -4
  91. package/src/widgets-v2/pie/download.test.ts +7 -3
  92. package/src/widgets-v2/pie/download.ts +5 -4
  93. package/src/widgets-v2/pie/options.test.ts +8 -0
  94. package/src/widgets-v2/pie/options.ts +3 -8
  95. package/src/widgets-v2/scatterplot/download.test.ts +4 -2
  96. package/src/widgets-v2/scatterplot/download.ts +5 -4
  97. package/src/widgets-v2/spread/download.test.ts +3 -1
  98. package/src/widgets-v2/spread/download.ts +5 -4
  99. package/src/widgets-v2/table/download.test.ts +5 -3
  100. package/src/widgets-v2/table/download.ts +4 -3
  101. package/src/widgets-v2/timeseries/download.test.ts +6 -4
  102. package/src/widgets-v2/timeseries/download.ts +5 -4
  103. package/src/widgets-v2/toolbox/toolbox.tsx +1 -1
  104. package/dist/actions-yPZwFxSZ.js +0 -1004
  105. package/dist/actions-yPZwFxSZ.js.map +0 -1
  106. package/dist/category-DGUDabsi.js +0 -829
  107. package/dist/category-DGUDabsi.js.map +0 -1
  108. package/dist/download-DdaK3qgC.js.map +0 -1
  109. package/dist/echart-DeBgdOtX.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,
@@ -13,13 +14,30 @@ import {
13
14
  DialogTitle,
14
15
  IconButton,
15
16
  Typography,
17
+ useMediaQuery,
18
+ useTheme,
16
19
  type SvgIconProps,
20
+ type CSSObject,
21
+ type SxProps,
22
+ type Theme,
17
23
  } from '@mui/material'
18
24
  import { Fullscreen as FullscreenIcon } from '@mui/icons-material'
19
25
  import { FullscreenExit as FullscreenExitIcon } from '@mui/icons-material'
20
26
  import { Close as CloseIcon } from '@mui/icons-material'
21
27
  import { Tooltip } from '../../../components'
22
- 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'
23
41
  import { DEFAULT_FULLSCREEN_LABELS, type FullScreenLabels } from './labels'
24
42
  import type { FullScreenWidgetState } from './types'
25
43
  import { styles } from './style'
@@ -33,13 +51,39 @@ export interface FullScreenTriggerProps {
33
51
 
34
52
  export interface FullScreenSlotProps {
35
53
  /**
36
- * Render-prop body. Receives the live `isFullScreen` flag so the consumer
37
- * can branch per-mode props (e.g., `Widget.Toolbox visibleCount`) without
38
- * 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.
39
64
  */
40
- children: (isFullScreen: boolean) => ReactNode
65
+ children: ReactNode | ((isFullScreen: boolean) => ReactNode)
41
66
  /** Header text rendered above the body when the modal is open. */
42
67
  title?: ReactNode
68
+ /**
69
+ * Buttons rendered in the modal header, to the left of the close icon —
70
+ * the fullscreen counterpart of `<Widget.Actions>`, which lives in the
71
+ * wrapper's summary row and is covered by the dialog. Pass the same
72
+ * elements you compose there (typically `<Widget.Download>`); the
73
+ * fullscreen trigger is intentionally not among them, since the close
74
+ * icon already exits.
75
+ */
76
+ actions?: ReactNode
77
+ /**
78
+ * Per-widget override merged over the default `90vw x 90vh` dialog paper.
79
+ * Charts want the default (their canvas grows to fill it); content-sized
80
+ * widgets (Range, Markdown) pass their own width / `height: 'auto'` so the
81
+ * modal doesn't leave a large empty box around them.
82
+ *
83
+ * Shallow-merged over the default, same as `Widget.Actions`' `sx`: pass a
84
+ * plain style object, not the array or theme-callback form.
85
+ */
86
+ paperSx?: SxProps<Theme>
43
87
  labels?: Partial<FullScreenLabels>
44
88
  closeIcon?: ComponentType<SvgIconProps>
45
89
  iconProps?: SvgIconProps
@@ -89,19 +133,48 @@ function FullScreenTrigger({
89
133
  )
90
134
  }
91
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
+
92
165
  /**
93
- * Body wrapper. Renders its render-prop children once, via a single
94
- * `createPortal` call whose container is a stable `<div>` we hold as a ref.
95
- * That div is moved imperatively between an inline placeholder and the
96
- * `<DialogContent>` of an MUI `<Dialog keepMounted>`. Because both the
97
- * React parent (the portal call site) AND the portal container are
98
- * identical across the toggle, the subtree keeps its fiber identity and
99
- * ECharts (or any other heavy child) stays mounted while only its DOM
100
- * 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.
101
172
  */
102
173
  function FullScreenSlot({
103
174
  children,
104
175
  title,
176
+ actions,
177
+ paperSx,
105
178
  labels,
106
179
  closeIcon: CloseSvg = CloseIcon,
107
180
  iconProps,
@@ -112,102 +185,215 @@ function FullScreenSlot({
112
185
  id,
113
186
  (s) => s.isFullScreen ?? false,
114
187
  )
115
- const [inlineEl, setInlineEl] = useState<HTMLElement | null>(null)
116
- const [dialogEl, setDialogEl] = useState<HTMLElement | null>(null)
117
- // Stable, never-changing portal container created once per slot. Held in
118
- // state (not a ref) so the React Compiler is happy reading it during
119
- // render — it's immutable from React's perspective, only its DOM parent
120
- // changes via `appendChild`. Switching `createPortal`'s `container`
121
- // argument makes React unmount and remount the children, defeating the
122
- // single-mount goal; keeping the container identical and moving the div
123
- // imperatively avoids that.
124
- const [portalDiv] = useState<HTMLDivElement | null>(() => {
125
- if (typeof document === 'undefined') return null
126
- const div = document.createElement('div')
127
- // `display: contents` keeps the wrapper transparent in flow so it
128
- // doesn't introduce a stray block when nested inside the inline slot.
129
- div.style.display = 'contents'
130
- return div
131
- })
132
-
188
+ // Below `sm` the dialog goes edge-to-edge (MUI's own answer for phones).
189
+ // A paper sized as a fraction of a 390px viewport is narrower than the
190
+ // card the widget sits in, and forcing it wider just eats the dialog's
191
+ // margins — on a screen that small, fullscreen should mean fullscreen.
192
+ const theme = useTheme()
193
+ const fullBleed = useMediaQuery(theme.breakpoints.down('sm'))
133
194
  const handleClose = useCallback(() => {
134
195
  getWidgetStore(id).setState({
135
196
  isFullScreen: false,
136
197
  } as Partial<FullScreenWidgetState>)
137
198
  }, [id])
138
199
 
139
- // Reparent the portal div into the active host whenever `open` (or either
140
- // host) changes. `useLayoutEffect` runs synchronously after commit (after
141
- // refs have been written), so the dialog ref is already live when we read
142
- // it.
143
- //
144
- // Anti-blink: pin a `min-height` on the inline shell that's always live —
145
- // not just while fullscreen is open. We re-measure whenever the body is
146
- // inline (open=false) so the lock tracks data-driven height changes
147
- // (Formula adding rows, Table growing, ECharts re-init…) and survives
148
- // every fullscreen toggle. Briefly clearing minHeight before the measure
149
- // lets the lock shrink as well as grow; both operations are synchronous
150
- // within useLayoutEffect, so the browser never paints the intermediate
151
- // "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)
152
205
  useLayoutEffect(() => {
153
- if (!portalDiv || !inlineEl) return
154
- const host = open ? dialogEl : inlineEl
155
- if (host && portalDiv.parentNode !== host) {
156
- 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
+ )
157
211
  }
158
- if (!open) refreshInlineHeightLock(inlineEl)
159
- }, [open, inlineEl, dialogEl, portalDiv])
212
+ wasOpen.current = open
213
+ }, [id, open])
160
214
 
161
215
  const titleId = `widget-fullscreen-title-${id}`
162
- const rendered = children(open)
216
+ const render = (inDialog: boolean) =>
217
+ typeof children === 'function' ? children(inDialog) : children
163
218
 
164
219
  return (
165
220
  <>
166
- <Box ref={setInlineEl} sx={styles.inlineSlot} />
167
- {portalDiv ? createPortal(rendered, portalDiv) : null}
221
+ <Box sx={styles.inlineSlot}>{render(false)}</Box>
168
222
  <Dialog
169
223
  open={open}
170
224
  onClose={handleClose}
171
- PaperProps={{ sx: styles.dialogPaper }}
225
+ fullScreen={fullBleed}
226
+ // Edge-to-edge owns the sizing; the floor and cap would fight it.
227
+ PaperProps={fullBleed ? undefined : { sx: resolvePaperSx(paperSx) }}
172
228
  aria-labelledby={titleId}
173
- // Sits above the Toolbox overflow Paper (zIndex.tooltip = 1500),
174
- // so opening fullscreen never leaves a stray popover floating on top.
175
- sx={{ zIndex: (theme) => theme.zIndex.tooltip + 1 }}
229
+ // Deliberately no z-index override. Raising the dialog above the
230
+ // tooltip layer also raises it above everything its own body opens —
231
+ // MUI portals menus and tooltips to `document.body`, so a Download
232
+ // menu (`zIndex.modal`, 1300) or a Tooltip (1500) ends up painted
233
+ // under a dialog at 1501 and its items can't be clicked. At the
234
+ // default modal layer the menu portals after the dialog and wins on
235
+ // DOM order, and the tooltip wins on z-index.
236
+ //
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.
176
239
  >
177
- <DialogTitle id={titleId} component='div' sx={styles.dialogTitle}>
178
- <Typography
179
- variant='subtitle1'
180
- component='span'
181
- sx={styles.titleText}
182
- >
183
- {title}
184
- </Typography>
185
- <Tooltip title={_labels.close}>
186
- <IconButton
187
- size='medium'
188
- aria-label={_labels.close}
189
- onClick={handleClose}
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}
190
246
  >
191
- <CloseSvg fontSize='small' {...iconProps} />
192
- </IconButton>
193
- </Tooltip>
194
- </DialogTitle>
195
- <DialogContent ref={setDialogEl} sx={styles.dialogContent} />
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>
196
266
  </Dialog>
197
267
  </>
198
268
  )
199
269
  }
200
270
 
201
271
  /**
202
- * Briefly clear the inline shell's `min-height`, measure its natural
203
- * height, then re-pin. Lives outside the component so the React Compiler
204
- * doesn't see the DOM mutation as an attempt to modify a `useState` value
205
- * — `el` here is just a parameter, not a tracked store value.
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
+
377
+ /**
378
+ * Merge a caller's `paperSx` over the default paper.
379
+ *
380
+ * An explicit `width` / `height` also clears the default's floor on that
381
+ * axis: `minWidth: 70vw` is 1008px on a 1440px screen, so a caller asking
382
+ * for `width: 960` would silently get 1008 and the escape hatch wouldn't
383
+ * escape. The viewport cap stays — a size larger than the screen helps
384
+ * nobody — and a caller who wants their own floor or cap can still set
385
+ * `minWidth` / `maxWidth` directly, since their values are spread last.
206
386
  */
207
- function refreshInlineHeightLock(el: HTMLElement): void {
208
- el.style.minHeight = ''
209
- const h = el.getBoundingClientRect().height
210
- if (h > 0) el.style.minHeight = `${h}px`
387
+ function resolvePaperSx(paperSx: SxProps<Theme> | undefined): SxProps<Theme> {
388
+ // Plain-object form only, as the prop documents — the array and
389
+ // theme-callback forms of `SxProps` wouldn't survive a shallow merge.
390
+ const plain = (paperSx ?? {}) as CSSObject
391
+ return {
392
+ ...styles.dialogPaper,
393
+ ...(plain.width !== undefined && { minWidth: 0 }),
394
+ ...(plain.height !== undefined && { minHeight: 0 }),
395
+ ...plain,
396
+ }
211
397
  }
212
398
 
213
399
  /**
@@ -218,8 +404,8 @@ function refreshInlineHeightLock(el: HTMLElement): void {
218
404
  * <Widget.FullScreen.Trigger />
219
405
  * </Widget.Actions>
220
406
  * <Widget.Content>
221
- * <Widget.FullScreen.Slot title={title}>
222
- * {(isFullScreen) => <Body visibleCount={isFullScreen ? undefined : 2} />}
407
+ * <Widget.FullScreen.Slot title={title} actions={<Widget.Download items={items} />}>
408
+ * <Body />
223
409
  * </Widget.FullScreen.Slot>
224
410
  * </Widget.Content>
225
411
  * ```
@@ -27,11 +27,25 @@ export const styles = {
27
27
  triggerActive: {
28
28
  background: (theme: Theme) => theme.palette.primary.relatedLight,
29
29
  },
30
+ // Sized by its content, between a floor and the viewport. The floor is
31
+ // what makes this safe: every widget body in the library is `width: 100%`
32
+ // (they're built to fill their card) and an ECharts canvas has no
33
+ // intrinsic size at all, so asking the content how big it wants to be
34
+ // gets no answer — without a floor the modal collapses to a few hundred
35
+ // pixels. Content bigger than the floor grows, up to the viewport cap.
36
+ //
37
+ // Floor and cap are both viewport units so the modal scales with the
38
+ // device: a px floor would be wider than the screen on a phone.
39
+ //
40
+ // Only applies from `sm` up — below that the dialog goes edge-to-edge and
41
+ // sizes itself (see `fullScreen` in fullscreen.tsx).
30
42
  dialogPaper: {
31
- width: '90vw',
32
- height: '90vh',
33
- maxWidth: 'none',
34
- maxHeight: 'none',
43
+ width: 'auto',
44
+ height: 'auto',
45
+ minWidth: '70vw',
46
+ minHeight: '70vh',
47
+ maxWidth: '90vw',
48
+ maxHeight: '90vh',
35
49
  },
36
50
  dialogTitle: {
37
51
  display: 'flex',
@@ -42,6 +56,15 @@ export const styles = {
42
56
  pr: 3,
43
57
  pb: 2,
44
58
  },
59
+ // Header buttons + close icon. Mirrors `Widget.Actions`' row so the modal
60
+ // header reads like the wrapper's summary row — minus the hover-fade,
61
+ // which makes no sense once the widget owns the whole screen.
62
+ dialogActions: {
63
+ display: 'flex',
64
+ alignItems: 'center',
65
+ gap: 0.5,
66
+ flexShrink: 0,
67
+ },
45
68
  titleText: {
46
69
  minWidth: 0,
47
70
  overflow: 'hidden',
@@ -55,5 +78,9 @@ export const styles = {
55
78
  py: 3,
56
79
  px: 2.5,
57
80
  overflow: 'auto',
81
+ // Lets the flex children grow into the dialog's height (and shrink below
82
+ // their content size) instead of being floored by it — without this the
83
+ // chart can't use the space the modal gives it.
84
+ minHeight: 0,
58
85
  },
59
86
  } satisfies Record<string, SxProps<Theme>>
@@ -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,
@@ -40,6 +40,20 @@ export function Searcher({
40
40
  string | undefined) ?? '',
41
41
  )
42
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
+
43
57
  const debouncedWrite = useMemo(
44
58
  () =>
45
59
  debounce(
@@ -78,6 +92,8 @@ export function Searcher({
78
92
  placeholder={resolved.placeholder}
79
93
  value={local}
80
94
  onChange={handleSearchTextChange}
95
+ onFocus={() => setFocused(true)}
96
+ onBlur={() => setFocused(false)}
81
97
  // The Searcher only mounts when the user explicitly toggles the
82
98
  // SearcherToggle action — focusing the input on mount matches user
83
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([])