@carto/ps-react-ui 6.0.0 → 6.1.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-BWlS5tpP.js +1150 -0
- package/dist/actions-BWlS5tpP.js.map +1 -0
- package/dist/category-alzjKzvQ.js +839 -0
- package/dist/category-alzjKzvQ.js.map +1 -0
- package/dist/{download-Vlzn2jzb.js → download-B4sykOFm.js} +94 -83
- package/dist/download-B4sykOFm.js.map +1 -0
- package/dist/{echart-DjcbtKgi.js → echart-CV8F50gn.js} +6 -5
- package/dist/echart-CV8F50gn.js.map +1 -0
- package/dist/{formula-CdHWtepq.js → formula-DJS-JLpk.js} +10 -16
- package/dist/{formula-CdHWtepq.js.map → formula-DJS-JLpk.js.map} +1 -1
- package/dist/legend.js +34 -21
- package/dist/legend.js.map +1 -1
- package/dist/{markdown-CY-67NRE.js → markdown-Bn2nKCbn.js} +12 -18
- package/dist/{markdown-CY-67NRE.js.map → markdown-Bn2nKCbn.js.map} +1 -1
- package/dist/{range-DHQSsl2_.js → range-BpU2zBmq.js} +2 -2
- package/dist/{range-DHQSsl2_.js.map → range-BpU2zBmq.js.map} +1 -1
- package/dist/{spread-DuM5oTI1.js → spread-CQwFIwuz.js} +10 -16
- package/dist/{spread-DuM5oTI1.js.map → spread-CQwFIwuz.js.map} +1 -1
- package/dist/{stores-B8CHbgw-.js → stores-DcBOZlU3.js} +30 -24
- package/dist/stores-DcBOZlU3.js.map +1 -0
- package/dist/{table-BE4uO9pp.js → table-DEdW_fDj.js} +7 -13
- package/dist/{table-BE4uO9pp.js.map → table-DEdW_fDj.js.map} +1 -1
- package/dist/types/legend/stores/types.d.ts +4 -2
- package/dist/types/widgets/actions/download/csv-item.d.ts +3 -3
- package/dist/types/widgets/actions/download/index.d.ts +2 -1
- package/dist/types/widgets/actions/download/png-item.d.ts +2 -2
- package/dist/types/widgets/actions/download/types.d.ts +12 -1
- package/dist/types/widgets/actions/download/widget-download.d.ts +26 -0
- package/dist/types/widgets/actions/fullscreen/fullscreen.d.ts +20 -13
- package/dist/types/widgets/actions/index.d.ts +1 -1
- package/dist/types/widgets/actions/searcher/filter.d.ts +3 -2
- package/dist/types/widgets/actions/searcher/searcher-toggle.d.ts +3 -0
- package/dist/types/widgets/bar/download.d.ts +6 -20
- package/dist/types/widgets/category/category-ui.d.ts +9 -1
- package/dist/types/widgets/category/download.d.ts +5 -10
- package/dist/types/widgets/category/helpers.d.ts +10 -0
- package/dist/types/widgets/echart/style.d.ts +1 -0
- package/dist/types/widgets/formula/download.d.ts +5 -15
- package/dist/types/widgets/histogram/download.d.ts +3 -8
- package/dist/types/widgets/index.d.ts +1 -1
- package/dist/types/widgets/markdown/download.d.ts +5 -11
- package/dist/types/widgets/pie/download.d.ts +3 -8
- package/dist/types/widgets/scatterplot/download.d.ts +6 -11
- package/dist/types/widgets/spread/download.d.ts +6 -12
- package/dist/types/widgets/stores/index.d.ts +1 -1
- package/dist/types/widgets/stores/widget-store-registry.d.ts +14 -0
- package/dist/types/widgets/table/download.d.ts +7 -13
- package/dist/types/widgets/timeseries/download.d.ts +7 -12
- package/dist/types/widgets/toolbox/toolbox.d.ts +1 -1
- package/dist/{utils-BStJWhZN.js → utils-C-eWwOhE.js} +2 -2
- package/dist/{utils-BStJWhZN.js.map → utils-C-eWwOhE.js.map} +1 -1
- package/dist/widgets/actions.js +4 -4
- package/dist/widgets/bar.js +11 -17
- package/dist/widgets/bar.js.map +1 -1
- package/dist/widgets/category.js +1 -1
- package/dist/widgets/echart.js +1 -1
- package/dist/widgets/formula.js +1 -1
- package/dist/widgets/histogram.js +14 -20
- package/dist/widgets/histogram.js.map +1 -1
- package/dist/widgets/markdown.js +1 -1
- package/dist/widgets/pie.js +13 -19
- package/dist/widgets/pie.js.map +1 -1
- package/dist/widgets/range.js +1 -1
- package/dist/widgets/scatterplot.js +13 -19
- package/dist/widgets/scatterplot.js.map +1 -1
- package/dist/widgets/spread.js +1 -1
- package/dist/widgets/stores.js +2 -2
- package/dist/widgets/table.js +1 -1
- package/dist/widgets/timeseries.js +16 -22
- package/dist/widgets/timeseries.js.map +1 -1
- package/dist/widgets/utils.js +1 -1
- package/dist/widgets.js +274 -333
- package/dist/widgets.js.map +1 -1
- package/dist/{zoom-toggle-D_VuA01l.js → zoom-toggle-MmLjMTEw.js} +2 -2
- package/dist/{zoom-toggle-D_VuA01l.js.map → zoom-toggle-MmLjMTEw.js.map} +1 -1
- package/package.json +1 -1
- package/src/legend/components/legend-item/legend-item-ui.test.tsx +22 -1
- package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +22 -6
- package/src/legend/stores/types.ts +4 -2
- package/src/widgets/actions/download/csv-item.test.tsx +3 -3
- package/src/widgets/actions/download/csv-item.tsx +6 -6
- package/src/widgets/actions/download/download.tsx +5 -3
- package/src/widgets/actions/download/index.ts +2 -1
- package/src/widgets/actions/download/png-item.test.tsx +5 -3
- package/src/widgets/actions/download/png-item.tsx +4 -4
- package/src/widgets/actions/download/types.ts +15 -1
- package/src/widgets/actions/download/widget-download.test.ts +30 -0
- package/src/widgets/actions/download/widget-download.ts +41 -0
- package/src/widgets/actions/fullscreen/fullscreen-store.test.tsx +155 -0
- package/src/widgets/actions/fullscreen/fullscreen.test.tsx +39 -41
- package/src/widgets/actions/fullscreen/fullscreen.tsx +198 -96
- package/src/widgets/actions/index.ts +3 -0
- package/src/widgets/actions/searcher/filter.test.ts +28 -0
- package/src/widgets/actions/searcher/filter.ts +22 -4
- package/src/widgets/actions/searcher/searcher-toggle.tsx +16 -8
- package/src/widgets/actions/searcher/searcher.test.tsx +22 -0
- package/src/widgets/actions/searcher/searcher.tsx +17 -7
- package/src/widgets/bar/download.test.tsx +5 -3
- package/src/widgets/bar/download.tsx +11 -35
- package/src/widgets/category/category-ui.tsx +26 -14
- package/src/widgets/category/category.tsx +37 -2
- package/src/widgets/category/components/category-legend.tsx +1 -2
- package/src/widgets/category/download.test.ts +4 -2
- package/src/widgets/category/download.ts +14 -27
- package/src/widgets/category/helpers.test.ts +17 -0
- package/src/widgets/category/helpers.ts +16 -0
- package/src/widgets/echart/style.ts +6 -0
- package/src/widgets/formula/download.test.tsx +3 -1
- package/src/widgets/formula/download.tsx +10 -30
- package/src/widgets/histogram/download.test.ts +6 -4
- package/src/widgets/histogram/download.ts +13 -26
- package/src/widgets/index.ts +1 -0
- package/src/widgets/markdown/download.test.ts +2 -2
- package/src/widgets/markdown/download.tsx +11 -26
- package/src/widgets/pie/download.test.ts +7 -3
- package/src/widgets/pie/download.ts +12 -25
- package/src/widgets/scatterplot/download.test.ts +4 -2
- package/src/widgets/scatterplot/download.ts +15 -28
- package/src/widgets/spread/download.test.ts +3 -1
- package/src/widgets/spread/download.ts +13 -27
- package/src/widgets/stores/index.ts +2 -0
- package/src/widgets/stores/widget-store-registry.test.ts +25 -0
- package/src/widgets/stores/widget-store-registry.ts +22 -0
- package/src/widgets/table/download.test.ts +5 -3
- package/src/widgets/table/download.ts +15 -29
- package/src/widgets/timeseries/download.test.ts +6 -4
- package/src/widgets/timeseries/download.ts +16 -29
- package/src/widgets/toolbox/toolbox.tsx +1 -1
- package/dist/actions-KsX1xdDM.js +0 -1024
- package/dist/actions-KsX1xdDM.js.map +0 -1
- package/dist/category-CsdDqDzo.js +0 -833
- package/dist/category-CsdDqDzo.js.map +0 -1
- package/dist/download-Vlzn2jzb.js.map +0 -1
- package/dist/echart-DjcbtKgi.js.map +0 -1
- package/dist/stores-B8CHbgw-.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 {
|
|
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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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.
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
191
|
-
}, [
|
|
212
|
+
wasOpen.current = open
|
|
213
|
+
}, [id, open])
|
|
192
214
|
|
|
193
215
|
const titleId = `widget-fullscreen-title-${id}`
|
|
194
|
-
const
|
|
216
|
+
const render = (inDialog: boolean) =>
|
|
217
|
+
typeof children === 'function' ? children(inDialog) : children
|
|
195
218
|
|
|
196
219
|
return (
|
|
197
220
|
<>
|
|
198
|
-
<Box
|
|
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}
|
|
@@ -214,41 +236,133 @@ function FullScreenSlot({
|
|
|
214
236
|
// default modal layer the menu portals after the dialog and wins on
|
|
215
237
|
// DOM order, and the tooltip wins on z-index.
|
|
216
238
|
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
// already does it: `visibleCount` flips to `undefined` in fullscreen,
|
|
220
|
-
// overflow disappears and the popper unmounts. A consumer who keeps a
|
|
221
|
-
// numeric `visibleCount` in fullscreen keeps its popper above the
|
|
222
|
-
// dialog — which is the correct layer for a popover opened from
|
|
223
|
-
// inside it anyway.
|
|
239
|
+
// A Toolbox popover opened from inside the dialog portals after it
|
|
240
|
+
// too, so it stays above the dialog — the right layer for it.
|
|
224
241
|
>
|
|
225
|
-
<
|
|
226
|
-
<
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
{
|
|
235
|
-
|
|
236
|
-
<
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
242
|
+
<DialogStore sourceId={id}>
|
|
243
|
+
<DialogTitle id={titleId} component='div' sx={styles.dialogTitle}>
|
|
244
|
+
<Typography
|
|
245
|
+
variant='subtitle1'
|
|
246
|
+
component='span'
|
|
247
|
+
sx={styles.titleText}
|
|
248
|
+
>
|
|
249
|
+
{title}
|
|
250
|
+
</Typography>
|
|
251
|
+
<Box sx={styles.dialogActions}>
|
|
252
|
+
{actions}
|
|
253
|
+
<Tooltip title={_labels.close}>
|
|
254
|
+
<IconButton
|
|
255
|
+
size='small'
|
|
256
|
+
aria-label={_labels.close}
|
|
257
|
+
onClick={handleClose}
|
|
258
|
+
>
|
|
259
|
+
<CloseSvg fontSize='small' {...iconProps} />
|
|
260
|
+
</IconButton>
|
|
261
|
+
</Tooltip>
|
|
262
|
+
</Box>
|
|
263
|
+
</DialogTitle>
|
|
264
|
+
<DialogContent sx={styles.dialogContent}>
|
|
265
|
+
{render(true)}
|
|
266
|
+
</DialogContent>
|
|
267
|
+
</DialogStore>
|
|
247
268
|
</Dialog>
|
|
248
269
|
</>
|
|
249
270
|
)
|
|
250
271
|
}
|
|
251
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Gives the dialog copy (header actions included) its own widget store,
|
|
275
|
+
* seeded from the card's when the dialog mounts, and keeps the consumer's
|
|
276
|
+
* props (data, loading, error, formatters) flowing into it from the card
|
|
277
|
+
* while it's open. Does the same for the chart, if the widget has one: the
|
|
278
|
+
* dialog's starts from the card's and hands its state back on close.
|
|
279
|
+
*/
|
|
280
|
+
function DialogStore({
|
|
281
|
+
sourceId,
|
|
282
|
+
children,
|
|
283
|
+
}: {
|
|
284
|
+
sourceId: string
|
|
285
|
+
children: ReactNode
|
|
286
|
+
}) {
|
|
287
|
+
const dialogId = fullScreenStoreId(sourceId)
|
|
288
|
+
// What the consumer feeds the card through its Provider props, in the
|
|
289
|
+
// same shape, so the dialog's Provider gets exactly the same.
|
|
290
|
+
const live = useWidgetShallow(sourceId, (s) => ({
|
|
291
|
+
data: s.rawData,
|
|
292
|
+
isLoading: s.isLoading,
|
|
293
|
+
isFetching: s.isFetching,
|
|
294
|
+
error: s.error,
|
|
295
|
+
formatter: s.rawFormatter,
|
|
296
|
+
labelFormatter: s.labelFormatter,
|
|
297
|
+
}))
|
|
298
|
+
// Runs before the Provider below looks the id up, so it adopts this
|
|
299
|
+
// store instead of creating an empty one.
|
|
300
|
+
useState(() => {
|
|
301
|
+
const store = createWidgetStore(dialogId, live)
|
|
302
|
+
store.setState(copiedState(getWidgetStore(sourceId).getState()))
|
|
303
|
+
setWidgetStoreEntry(dialogId, store)
|
|
304
|
+
return store
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
// A layout effect on purpose. It subscribes before the dialog chart's
|
|
308
|
+
// (passive) init effect publishes it, and its cleanup runs before that
|
|
309
|
+
// chart's (passive) dispose — so the chart is still alive to copy from.
|
|
310
|
+
useLayoutEffect(() => {
|
|
311
|
+
let dialogChart: ECharts | null = null
|
|
312
|
+
const unsubscribe = subscribeEchartInstance(dialogId, (chart) => {
|
|
313
|
+
// Published from inside the chart's init, before its first
|
|
314
|
+
// setOption, so the dialog's own option merges on top of the copy.
|
|
315
|
+
const card = liveChart(sourceId)
|
|
316
|
+
if (chart && card) copyChartState(card, chart)
|
|
317
|
+
dialogChart = chart
|
|
318
|
+
})
|
|
319
|
+
return () => {
|
|
320
|
+
unsubscribe()
|
|
321
|
+
const card = liveChart(sourceId)
|
|
322
|
+
if (card && dialogChart && !dialogChart.isDisposed()) {
|
|
323
|
+
copyChartState(dialogChart, card)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}, [dialogId, sourceId])
|
|
327
|
+
|
|
328
|
+
return (
|
|
329
|
+
<Provider id={dialogId} {...live}>
|
|
330
|
+
{children}
|
|
331
|
+
</Provider>
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function liveChart(id: string): ECharts | null {
|
|
336
|
+
const chart = getEchartInstance(id)
|
|
337
|
+
return chart && !chart.isDisposed() ? chart : null
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Put on `chart` what `peer` is showing: the legend selection, the dataZoom
|
|
342
|
+
* window and any drawn brush areas, which live in the ECharts instance
|
|
343
|
+
* rather than the widget store.
|
|
344
|
+
*/
|
|
345
|
+
function copyChartState(peer: ECharts, chart: ECharts): void {
|
|
346
|
+
chart.setOption(peer.getOption())
|
|
347
|
+
// The pointer was over `peer`, not `chart`: drop the tooltip and axis
|
|
348
|
+
// pointer that came along, or they'd stay drawn until the next hover.
|
|
349
|
+
chart.dispatchAction({ type: 'hideTip' })
|
|
350
|
+
chart.dispatchAction({ type: 'updateAxisPointer', currTrigger: 'leave' })
|
|
351
|
+
// Brush areas sit on the brush model, not in `getOption()`. `getModel` is
|
|
352
|
+
// private in the typings but stable across 5.x.
|
|
353
|
+
// ponytail: private API; if it goes away, track areas from the `brush` event instead.
|
|
354
|
+
const areas = (
|
|
355
|
+
peer as unknown as {
|
|
356
|
+
getModel?: () => {
|
|
357
|
+
getComponent: (type: string) => { areas?: unknown[] } | undefined
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
)
|
|
361
|
+
.getModel?.()
|
|
362
|
+
.getComponent('brush')?.areas
|
|
363
|
+
if (areas?.length) chart.dispatchAction({ type: 'brush', areas })
|
|
364
|
+
}
|
|
365
|
+
|
|
252
366
|
/**
|
|
253
367
|
* Merge a caller's `paperSx` over the default paper.
|
|
254
368
|
*
|
|
@@ -271,18 +385,6 @@ function resolvePaperSx(paperSx: SxProps<Theme> | undefined): SxProps<Theme> {
|
|
|
271
385
|
}
|
|
272
386
|
}
|
|
273
387
|
|
|
274
|
-
/**
|
|
275
|
-
* Briefly clear the inline shell's `min-height`, measure its natural
|
|
276
|
-
* height, then re-pin. Lives outside the component so the React Compiler
|
|
277
|
-
* doesn't see the DOM mutation as an attempt to modify a `useState` value
|
|
278
|
-
* — `el` here is just a parameter, not a tracked store value.
|
|
279
|
-
*/
|
|
280
|
-
function refreshInlineHeightLock(el: HTMLElement): void {
|
|
281
|
-
el.style.minHeight = ''
|
|
282
|
-
const h = el.getBoundingClientRect().height
|
|
283
|
-
if (h > 0) el.style.minHeight = `${h}px`
|
|
284
|
-
}
|
|
285
|
-
|
|
286
388
|
/**
|
|
287
389
|
* Namespace export. Consumers compose with:
|
|
288
390
|
*
|
|
@@ -292,7 +394,7 @@ function refreshInlineHeightLock(el: HTMLElement): void {
|
|
|
292
394
|
* </Widget.Actions>
|
|
293
395
|
* <Widget.Content>
|
|
294
396
|
* <Widget.FullScreen.Slot title={title} actions={<Widget.Download items={items} />}>
|
|
295
|
-
*
|
|
397
|
+
* <Body />
|
|
296
398
|
* </Widget.FullScreen.Slot>
|
|
297
399
|
* </Widget.Content>
|
|
298
400
|
* ```
|
|
@@ -55,6 +55,7 @@ export {
|
|
|
55
55
|
triggerLinkDownload,
|
|
56
56
|
buildPngDownloadItem,
|
|
57
57
|
buildCsvDownloadItem,
|
|
58
|
+
withPngItem,
|
|
58
59
|
CSVIcon,
|
|
59
60
|
PNGIcon,
|
|
60
61
|
MarkdownIcon,
|
|
@@ -62,11 +63,13 @@ export {
|
|
|
62
63
|
DOWNLOAD_ITEM_IDS,
|
|
63
64
|
type DownloadProps,
|
|
64
65
|
type DownloadItem,
|
|
66
|
+
type DownloadContext,
|
|
65
67
|
type DownloadItemId,
|
|
66
68
|
type DownloadHandle,
|
|
67
69
|
type DownloadDOMToPNGOptions,
|
|
68
70
|
type BuildPngDownloadItemArgs,
|
|
69
71
|
type BuildCsvDownloadItemArgs,
|
|
72
|
+
type WidgetDownloadArgs,
|
|
70
73
|
type DownloadLabels,
|
|
71
74
|
} from './download'
|
|
72
75
|
export {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { filterBySearchText } from './filter'
|
|
3
|
+
|
|
4
|
+
const DATA = [
|
|
5
|
+
[
|
|
6
|
+
{ name: 'ES', value: 1 },
|
|
7
|
+
{ name: 'FR', value: 2 },
|
|
8
|
+
],
|
|
9
|
+
]
|
|
10
|
+
const COUNTRIES: Record<string, string> = { ES: 'Spain', FR: 'France' }
|
|
11
|
+
const label = (v: string | number) => COUNTRIES[v] ?? v
|
|
12
|
+
|
|
13
|
+
describe('filterBySearchText', () => {
|
|
14
|
+
it('matches the raw name when there is no label formatter', () => {
|
|
15
|
+
expect(filterBySearchText(DATA, 'es')).toEqual([[{ name: 'ES', value: 1 }]])
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('matches the formatted label, which is what the user sees', () => {
|
|
19
|
+
expect(filterBySearchText(DATA, 'spa', label)).toEqual([
|
|
20
|
+
[{ name: 'ES', value: 1 }],
|
|
21
|
+
])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('no longer matches a raw name the label does not show', () => {
|
|
25
|
+
// "es" is in the raw code "ES" but in neither "Spain" nor "France".
|
|
26
|
+
expect(filterBySearchText(DATA, 'es', label)).toEqual([[]])
|
|
27
|
+
})
|
|
28
|
+
})
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Filters a 2D series of `{name, ...}` records by case-insensitive substring on
|
|
3
|
-
* the
|
|
3
|
+
* the label the user sees: `labelFormatter(name)` when a formatter is given,
|
|
4
|
+
* `name` otherwise. Keeps the dataset shape (one inner array per series).
|
|
4
5
|
* Used by the Searcher action's data transform.
|
|
5
6
|
*/
|
|
6
|
-
export function filterBySearchText(
|
|
7
|
+
export function filterBySearchText(
|
|
8
|
+
input: unknown,
|
|
9
|
+
search: string,
|
|
10
|
+
labelFormatter?: (value: string | number) => string | number,
|
|
11
|
+
): unknown {
|
|
7
12
|
if (!search) return input
|
|
8
13
|
const needle = search.toLowerCase()
|
|
9
14
|
if (!Array.isArray(input)) return input
|
|
@@ -13,14 +18,27 @@ export function filterBySearchText(input: unknown, search: string): unknown {
|
|
|
13
18
|
if (item == null) return false
|
|
14
19
|
if (typeof item === 'object' && 'name' in item) {
|
|
15
20
|
const rawName = (item as { name: unknown }).name
|
|
16
|
-
|
|
17
|
-
return name.toLowerCase().includes(needle)
|
|
21
|
+
return labelOf(rawName, labelFormatter).toLowerCase().includes(needle)
|
|
18
22
|
}
|
|
19
23
|
return stringifyPrimitive(item).toLowerCase().includes(needle)
|
|
20
24
|
})
|
|
21
25
|
})
|
|
22
26
|
}
|
|
23
27
|
|
|
28
|
+
function labelOf(
|
|
29
|
+
name: unknown,
|
|
30
|
+
labelFormatter?: (value: string | number) => string | number,
|
|
31
|
+
): string {
|
|
32
|
+
if (name == null) return ''
|
|
33
|
+
if (
|
|
34
|
+
labelFormatter &&
|
|
35
|
+
(typeof name === 'string' || typeof name === 'number')
|
|
36
|
+
) {
|
|
37
|
+
return stringifyPrimitive(labelFormatter(name))
|
|
38
|
+
}
|
|
39
|
+
return stringifyPrimitive(name)
|
|
40
|
+
}
|
|
41
|
+
|
|
24
42
|
function stringifyPrimitive(v: unknown): string {
|
|
25
43
|
if (typeof v === 'string') return v
|
|
26
44
|
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
useSingleTransform,
|
|
8
8
|
useWidget,
|
|
9
9
|
useWidgetId,
|
|
10
|
+
type WidgetState,
|
|
10
11
|
} from '../../stores'
|
|
11
12
|
import { filterBySearchText } from './filter'
|
|
12
13
|
import { DEFAULT_SEARCHER_LABELS, type SearcherLabels } from './labels'
|
|
@@ -41,16 +42,18 @@ export function SearcherToggle({
|
|
|
41
42
|
}: SearcherToggleProps) {
|
|
42
43
|
const id = useWidgetId()
|
|
43
44
|
const resolved = { ...DEFAULT_SEARCHER_LABELS, ...labels }
|
|
44
|
-
const text = useWidget(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
const text = useWidget(id, selectSearchText)
|
|
46
|
+
|
|
47
|
+
// Matched against the label the user sees, so a formatted name is found
|
|
48
|
+
// by what's on screen rather than by its raw value.
|
|
49
|
+
const labelFormatter = useWidget(id, (s) => s.labelFormatter)
|
|
48
50
|
|
|
49
|
-
// The transform fn closes over `text`. Memoize via
|
|
50
|
-
// only changes when
|
|
51
|
+
// The transform fn closes over `text` and `labelFormatter`. Memoize via
|
|
52
|
+
// useCallback so identity only changes when either does, allowing
|
|
53
|
+
// useTransform to re-register.
|
|
51
54
|
const fn = useCallback(
|
|
52
|
-
(data: unknown) => filterBySearchText(data, text),
|
|
53
|
-
[text],
|
|
55
|
+
(data: unknown) => filterBySearchText(data, text, labelFormatter),
|
|
56
|
+
[text, labelFormatter],
|
|
54
57
|
)
|
|
55
58
|
|
|
56
59
|
const { enabled, toggle } = useSingleTransform(id, SEARCHER_DESCRIPTOR, fn, {
|
|
@@ -72,6 +75,11 @@ export function SearcherToggle({
|
|
|
72
75
|
)
|
|
73
76
|
}
|
|
74
77
|
|
|
78
|
+
/** The widget's current search text; empty until someone types. */
|
|
79
|
+
export function selectSearchText(s: WidgetState): string {
|
|
80
|
+
return (s.transformStates.searcher?.searchText as string | undefined) ?? ''
|
|
81
|
+
}
|
|
82
|
+
|
|
75
83
|
/**
|
|
76
84
|
* Imperatively writes the search text into the widget store. Used by the
|
|
77
85
|
* Searcher input to update text inside transformStates without going through
|
|
@@ -89,4 +89,26 @@ describe('<Searcher> + <SearcherToggle>', () => {
|
|
|
89
89
|
vi.useRealTimers()
|
|
90
90
|
}
|
|
91
91
|
})
|
|
92
|
+
|
|
93
|
+
it('filters by the formatted label the widget shows', () => {
|
|
94
|
+
const labelFormatter = (v: string | number) =>
|
|
95
|
+
({ apple: 'Manzana', banana: 'Plátano' })[String(v)] ?? v
|
|
96
|
+
render(
|
|
97
|
+
<Provider id='sr-label' data={DATA} labelFormatter={labelFormatter}>
|
|
98
|
+
<SearcherToggle initialEnabled />
|
|
99
|
+
<Searcher />
|
|
100
|
+
</Provider>,
|
|
101
|
+
)
|
|
102
|
+
act(() =>
|
|
103
|
+
getWidgetStore('sr-label').setState((s) => ({
|
|
104
|
+
transformStates: {
|
|
105
|
+
...s.transformStates,
|
|
106
|
+
searcher: { ...s.transformStates.searcher!, searchText: 'manz' },
|
|
107
|
+
},
|
|
108
|
+
})),
|
|
109
|
+
)
|
|
110
|
+
expect(getWidgetStore('sr-label').getState().data).toEqual([
|
|
111
|
+
[{ name: 'apple', value: 1 }],
|
|
112
|
+
])
|
|
113
|
+
})
|
|
92
114
|
})
|
|
@@ -3,8 +3,8 @@ import { IconButton, InputAdornment, TextField } from '@mui/material'
|
|
|
3
3
|
import { Search as SearchIcon } from '@mui/icons-material'
|
|
4
4
|
import { Clear as ClearIcon } from '@mui/icons-material'
|
|
5
5
|
import { debounce } from '@carto/ps-utils'
|
|
6
|
-
import {
|
|
7
|
-
import { setSearcherText } from './searcher-toggle'
|
|
6
|
+
import { useWidget, useWidgetId } from '../../stores'
|
|
7
|
+
import { selectSearchText, setSearcherText } from './searcher-toggle'
|
|
8
8
|
import { DEFAULT_SEARCHER_LABELS, type SearcherLabels } from './labels'
|
|
9
9
|
import { styles } from './style'
|
|
10
10
|
|
|
@@ -33,12 +33,20 @@ export function Searcher({
|
|
|
33
33
|
|
|
34
34
|
const inputRef = useRef<HTMLInputElement | null>(null)
|
|
35
35
|
|
|
36
|
+
const stored = useWidget(id, selectSearchText)
|
|
36
37
|
// Hydrate from the store so re-toggling the input preserves any prior text.
|
|
37
|
-
const [local, setLocal] = useState
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
const [local, setLocal] = useState(stored)
|
|
39
|
+
|
|
40
|
+
// Follow text written by another Searcher of the same widget (the card
|
|
41
|
+
// and the fullscreen copy each render one). Skipped while this input has
|
|
42
|
+
// focus: its own debounced write lands late, and adopting it would drop
|
|
43
|
+
// whatever was typed since.
|
|
44
|
+
const [focused, setFocused] = useState(false)
|
|
45
|
+
const [prevStored, setPrevStored] = useState(stored)
|
|
46
|
+
if (stored !== prevStored) {
|
|
47
|
+
setPrevStored(stored)
|
|
48
|
+
if (!focused) setLocal(stored)
|
|
49
|
+
}
|
|
42
50
|
|
|
43
51
|
const debouncedWrite = useMemo(
|
|
44
52
|
() =>
|
|
@@ -78,6 +86,8 @@ export function Searcher({
|
|
|
78
86
|
placeholder={resolved.placeholder}
|
|
79
87
|
value={local}
|
|
80
88
|
onChange={handleSearchTextChange}
|
|
89
|
+
onFocus={() => setFocused(true)}
|
|
90
|
+
onBlur={() => setFocused(false)}
|
|
81
91
|
// The Searcher only mounts when the user explicitly toggles the
|
|
82
92
|
// SearcherToggle action — focusing the input on mount matches user
|
|
83
93
|
// intent, so the jsx-a11y/no-autofocus warning is the false-positive
|
|
@@ -50,7 +50,9 @@ describe('createBarDownloadConfig', () => {
|
|
|
50
50
|
})
|
|
51
51
|
const png = items.find((i) => i.id === 'png')
|
|
52
52
|
expect(png).toBeTruthy()
|
|
53
|
-
await expect(png!.resolve()).rejects.toThrow(
|
|
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
|
})
|