@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.
- package/dist/actions-CF8RNKd6.js +1161 -0
- package/dist/actions-CF8RNKd6.js.map +1 -0
- package/dist/category-D01ruG-l.js +842 -0
- package/dist/category-D01ruG-l.js.map +1 -0
- package/dist/{download-DdaK3qgC.js → download-DGgFOaXj.js} +81 -80
- package/dist/download-DGgFOaXj.js.map +1 -0
- package/dist/{echart-DeBgdOtX.js → echart-CwzbPElc.js} +29 -29
- package/dist/echart-CwzbPElc.js.map +1 -0
- package/dist/{formula-noJzQwKo.js → formula-BIxJr1P3.js} +6 -6
- package/dist/{formula-noJzQwKo.js.map → formula-BIxJr1P3.js.map} +1 -1
- package/dist/{markdown-2dfL6Cmb.js → markdown-oo9mnJun.js} +6 -6
- package/dist/{markdown-2dfL6Cmb.js.map → markdown-oo9mnJun.js.map} +1 -1
- package/dist/{spread-Ccpg9fi0.js → spread-CgCTKPQD.js} +7 -7
- package/dist/{spread-Ccpg9fi0.js.map → spread-CgCTKPQD.js.map} +1 -1
- package/dist/{table-Bx9Uj3U0.js → table-lb5momun.js} +3 -3
- package/dist/{table-Bx9Uj3U0.js.map → table-lb5momun.js.map} +1 -1
- package/dist/types/widgets-v2/actions/download/csv-item.d.ts +3 -3
- package/dist/types/widgets-v2/actions/download/index.d.ts +1 -1
- package/dist/types/widgets-v2/actions/download/png-item.d.ts +2 -2
- package/dist/types/widgets-v2/actions/download/types.d.ts +12 -1
- package/dist/types/widgets-v2/actions/fullscreen/fullscreen.d.ts +42 -16
- package/dist/types/widgets-v2/actions/fullscreen/style.d.ts +9 -0
- package/dist/types/widgets-v2/actions/index.d.ts +1 -1
- package/dist/types/widgets-v2/bar/download.d.ts +3 -3
- package/dist/types/widgets-v2/category/category-ui.d.ts +9 -1
- package/dist/types/widgets-v2/category/download.d.ts +3 -3
- package/dist/types/widgets-v2/echart/echart-ui.d.ts +2 -2
- package/dist/types/widgets-v2/echart/echart.d.ts +9 -1
- package/dist/types/widgets-v2/echart/style.d.ts +2 -0
- package/dist/types/widgets-v2/formula/download.d.ts +3 -3
- package/dist/types/widgets-v2/histogram/download.d.ts +3 -3
- package/dist/types/widgets-v2/index.d.ts +2 -2
- package/dist/types/widgets-v2/markdown/download.d.ts +3 -3
- package/dist/types/widgets-v2/pie/download.d.ts +3 -3
- package/dist/types/widgets-v2/scatterplot/download.d.ts +3 -3
- package/dist/types/widgets-v2/spread/download.d.ts +3 -3
- package/dist/types/widgets-v2/table/download.d.ts +3 -3
- package/dist/types/widgets-v2/timeseries/download.d.ts +3 -3
- package/dist/types/widgets-v2/toolbox/toolbox.d.ts +1 -1
- package/dist/widgets-v2/actions.js +2 -2
- package/dist/widgets-v2/bar.js +7 -7
- package/dist/widgets-v2/bar.js.map +1 -1
- package/dist/widgets-v2/category.js +1 -1
- package/dist/widgets-v2/echart.js +1 -1
- package/dist/widgets-v2/formula.js +1 -1
- package/dist/widgets-v2/histogram.js +10 -10
- package/dist/widgets-v2/histogram.js.map +1 -1
- package/dist/widgets-v2/markdown.js +1 -1
- package/dist/widgets-v2/pie.js +10 -11
- package/dist/widgets-v2/pie.js.map +1 -1
- package/dist/widgets-v2/scatterplot.js +9 -9
- package/dist/widgets-v2/scatterplot.js.map +1 -1
- package/dist/widgets-v2/spread.js +1 -1
- package/dist/widgets-v2/table.js +1 -1
- package/dist/widgets-v2/timeseries.js +12 -12
- package/dist/widgets-v2/timeseries.js.map +1 -1
- package/dist/widgets-v2.js +274 -333
- package/dist/widgets-v2.js.map +1 -1
- package/package.json +1 -1
- package/src/widgets-v2/actions/download/csv-item.test.tsx +3 -3
- package/src/widgets-v2/actions/download/csv-item.tsx +6 -6
- package/src/widgets-v2/actions/download/download.tsx +5 -3
- package/src/widgets-v2/actions/download/index.ts +1 -1
- package/src/widgets-v2/actions/download/png-item.test.tsx +5 -3
- package/src/widgets-v2/actions/download/png-item.tsx +4 -4
- package/src/widgets-v2/actions/download/types.ts +15 -1
- package/src/widgets-v2/actions/fullscreen/fullscreen-store.test.tsx +155 -0
- package/src/widgets-v2/actions/fullscreen/fullscreen.test.tsx +93 -33
- package/src/widgets-v2/actions/fullscreen/fullscreen.tsx +272 -86
- package/src/widgets-v2/actions/fullscreen/style.ts +31 -4
- package/src/widgets-v2/actions/index.ts +1 -0
- package/src/widgets-v2/actions/searcher/searcher.tsx +16 -0
- package/src/widgets-v2/bar/download.test.tsx +5 -3
- package/src/widgets-v2/bar/download.tsx +5 -4
- package/src/widgets-v2/category/category-ui.tsx +24 -13
- package/src/widgets-v2/category/category.tsx +37 -2
- package/src/widgets-v2/category/download.test.ts +4 -2
- package/src/widgets-v2/category/download.ts +5 -4
- package/src/widgets-v2/echart/echart-ui.test.tsx +6 -3
- package/src/widgets-v2/echart/echart-ui.tsx +6 -2
- package/src/widgets-v2/echart/echart.test.tsx +1 -1
- package/src/widgets-v2/echart/echart.tsx +10 -0
- package/src/widgets-v2/echart/style.ts +16 -1
- package/src/widgets-v2/formula/download.test.tsx +3 -1
- package/src/widgets-v2/formula/download.tsx +5 -4
- package/src/widgets-v2/histogram/download.test.ts +6 -4
- package/src/widgets-v2/histogram/download.ts +5 -4
- package/src/widgets-v2/index.ts +1 -0
- package/src/widgets-v2/markdown/download.test.ts +2 -2
- package/src/widgets-v2/markdown/download.tsx +5 -4
- package/src/widgets-v2/pie/download.test.ts +7 -3
- package/src/widgets-v2/pie/download.ts +5 -4
- package/src/widgets-v2/pie/options.test.ts +8 -0
- package/src/widgets-v2/pie/options.ts +3 -8
- package/src/widgets-v2/scatterplot/download.test.ts +4 -2
- package/src/widgets-v2/scatterplot/download.ts +5 -4
- package/src/widgets-v2/spread/download.test.ts +3 -1
- package/src/widgets-v2/spread/download.ts +5 -4
- package/src/widgets-v2/table/download.test.ts +5 -3
- package/src/widgets-v2/table/download.ts +4 -3
- package/src/widgets-v2/timeseries/download.test.ts +6 -4
- package/src/widgets-v2/timeseries/download.ts +5 -4
- package/src/widgets-v2/toolbox/toolbox.tsx +1 -1
- package/dist/actions-yPZwFxSZ.js +0 -1004
- package/dist/actions-yPZwFxSZ.js.map +0 -1
- package/dist/category-DGUDabsi.js +0 -829
- package/dist/category-DGUDabsi.js.map +0 -1
- package/dist/download-DdaK3qgC.js.map +0 -1
- 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 {
|
|
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
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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.
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
159
|
-
}, [
|
|
212
|
+
wasOpen.current = open
|
|
213
|
+
}, [id, open])
|
|
160
214
|
|
|
161
215
|
const titleId = `widget-fullscreen-title-${id}`
|
|
162
|
-
const
|
|
216
|
+
const render = (inDialog: boolean) =>
|
|
217
|
+
typeof children === 'function' ? children(inDialog) : children
|
|
163
218
|
|
|
164
219
|
return (
|
|
165
220
|
<>
|
|
166
|
-
<Box
|
|
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
|
-
|
|
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
|
-
//
|
|
174
|
-
//
|
|
175
|
-
|
|
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
|
-
<
|
|
178
|
-
<
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
192
|
-
</
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
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
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
*
|
|
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: '
|
|
32
|
-
height: '
|
|
33
|
-
|
|
34
|
-
|
|
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>>
|
|
@@ -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(
|
|
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([])
|