@carto/ps-react-ui 6.0.0 → 6.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/dist/actions-BWlS5tpP.js +1150 -0
  2. package/dist/actions-BWlS5tpP.js.map +1 -0
  3. package/dist/category-alzjKzvQ.js +839 -0
  4. package/dist/category-alzjKzvQ.js.map +1 -0
  5. package/dist/{download-Vlzn2jzb.js → download-B4sykOFm.js} +94 -83
  6. package/dist/download-B4sykOFm.js.map +1 -0
  7. package/dist/{echart-DjcbtKgi.js → echart-CV8F50gn.js} +6 -5
  8. package/dist/echart-CV8F50gn.js.map +1 -0
  9. package/dist/{formula-CdHWtepq.js → formula-DJS-JLpk.js} +10 -16
  10. package/dist/{formula-CdHWtepq.js.map → formula-DJS-JLpk.js.map} +1 -1
  11. package/dist/legend.js +34 -21
  12. package/dist/legend.js.map +1 -1
  13. package/dist/{markdown-CY-67NRE.js → markdown-Bn2nKCbn.js} +12 -18
  14. package/dist/{markdown-CY-67NRE.js.map → markdown-Bn2nKCbn.js.map} +1 -1
  15. package/dist/{range-DHQSsl2_.js → range-BpU2zBmq.js} +2 -2
  16. package/dist/{range-DHQSsl2_.js.map → range-BpU2zBmq.js.map} +1 -1
  17. package/dist/{spread-DuM5oTI1.js → spread-CQwFIwuz.js} +10 -16
  18. package/dist/{spread-DuM5oTI1.js.map → spread-CQwFIwuz.js.map} +1 -1
  19. package/dist/{stores-B8CHbgw-.js → stores-DcBOZlU3.js} +30 -24
  20. package/dist/stores-DcBOZlU3.js.map +1 -0
  21. package/dist/{table-BE4uO9pp.js → table-DEdW_fDj.js} +7 -13
  22. package/dist/{table-BE4uO9pp.js.map → table-DEdW_fDj.js.map} +1 -1
  23. package/dist/types/legend/stores/types.d.ts +4 -2
  24. package/dist/types/widgets/actions/download/csv-item.d.ts +3 -3
  25. package/dist/types/widgets/actions/download/index.d.ts +2 -1
  26. package/dist/types/widgets/actions/download/png-item.d.ts +2 -2
  27. package/dist/types/widgets/actions/download/types.d.ts +12 -1
  28. package/dist/types/widgets/actions/download/widget-download.d.ts +26 -0
  29. package/dist/types/widgets/actions/fullscreen/fullscreen.d.ts +20 -13
  30. package/dist/types/widgets/actions/index.d.ts +1 -1
  31. package/dist/types/widgets/actions/searcher/filter.d.ts +3 -2
  32. package/dist/types/widgets/actions/searcher/searcher-toggle.d.ts +3 -0
  33. package/dist/types/widgets/bar/download.d.ts +6 -20
  34. package/dist/types/widgets/category/category-ui.d.ts +9 -1
  35. package/dist/types/widgets/category/download.d.ts +5 -10
  36. package/dist/types/widgets/category/helpers.d.ts +10 -0
  37. package/dist/types/widgets/echart/style.d.ts +1 -0
  38. package/dist/types/widgets/formula/download.d.ts +5 -15
  39. package/dist/types/widgets/histogram/download.d.ts +3 -8
  40. package/dist/types/widgets/index.d.ts +1 -1
  41. package/dist/types/widgets/markdown/download.d.ts +5 -11
  42. package/dist/types/widgets/pie/download.d.ts +3 -8
  43. package/dist/types/widgets/scatterplot/download.d.ts +6 -11
  44. package/dist/types/widgets/spread/download.d.ts +6 -12
  45. package/dist/types/widgets/stores/index.d.ts +1 -1
  46. package/dist/types/widgets/stores/widget-store-registry.d.ts +14 -0
  47. package/dist/types/widgets/table/download.d.ts +7 -13
  48. package/dist/types/widgets/timeseries/download.d.ts +7 -12
  49. package/dist/types/widgets/toolbox/toolbox.d.ts +1 -1
  50. package/dist/types/widgets/wrapper/style.d.ts +31 -2
  51. package/dist/types/widgets/wrapper/widget-actions.d.ts +2 -2
  52. package/dist/types/widgets/wrapper/widget-wrapper.d.ts +2 -2
  53. package/dist/{utils-BStJWhZN.js → utils-C-eWwOhE.js} +2 -2
  54. package/dist/{utils-BStJWhZN.js.map → utils-C-eWwOhE.js.map} +1 -1
  55. package/dist/widgets/actions.js +4 -4
  56. package/dist/widgets/bar.js +11 -17
  57. package/dist/widgets/bar.js.map +1 -1
  58. package/dist/widgets/category.js +1 -1
  59. package/dist/widgets/echart.js +1 -1
  60. package/dist/widgets/formula.js +1 -1
  61. package/dist/widgets/histogram.js +14 -20
  62. package/dist/widgets/histogram.js.map +1 -1
  63. package/dist/widgets/markdown.js +1 -1
  64. package/dist/widgets/pie.js +13 -19
  65. package/dist/widgets/pie.js.map +1 -1
  66. package/dist/widgets/range.js +1 -1
  67. package/dist/widgets/scatterplot.js +13 -19
  68. package/dist/widgets/scatterplot.js.map +1 -1
  69. package/dist/widgets/spread.js +1 -1
  70. package/dist/widgets/stores.js +2 -2
  71. package/dist/widgets/table.js +1 -1
  72. package/dist/widgets/timeseries.js +16 -22
  73. package/dist/widgets/timeseries.js.map +1 -1
  74. package/dist/widgets/utils.js +1 -1
  75. package/dist/widgets.js +364 -376
  76. package/dist/widgets.js.map +1 -1
  77. package/dist/{zoom-toggle-D_VuA01l.js → zoom-toggle-MmLjMTEw.js} +2 -2
  78. package/dist/{zoom-toggle-D_VuA01l.js.map → zoom-toggle-MmLjMTEw.js.map} +1 -1
  79. package/package.json +1 -1
  80. package/src/legend/components/legend-item/legend-item-ui.test.tsx +22 -1
  81. package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +22 -6
  82. package/src/legend/stores/types.ts +4 -2
  83. package/src/widgets/actions/download/csv-item.test.tsx +3 -3
  84. package/src/widgets/actions/download/csv-item.tsx +6 -6
  85. package/src/widgets/actions/download/download.tsx +5 -3
  86. package/src/widgets/actions/download/index.ts +2 -1
  87. package/src/widgets/actions/download/png-item.test.tsx +5 -3
  88. package/src/widgets/actions/download/png-item.tsx +4 -4
  89. package/src/widgets/actions/download/types.ts +15 -1
  90. package/src/widgets/actions/download/widget-download.test.ts +30 -0
  91. package/src/widgets/actions/download/widget-download.ts +41 -0
  92. package/src/widgets/actions/fullscreen/fullscreen-store.test.tsx +155 -0
  93. package/src/widgets/actions/fullscreen/fullscreen.test.tsx +39 -41
  94. package/src/widgets/actions/fullscreen/fullscreen.tsx +198 -96
  95. package/src/widgets/actions/index.ts +3 -0
  96. package/src/widgets/actions/searcher/filter.test.ts +28 -0
  97. package/src/widgets/actions/searcher/filter.ts +22 -4
  98. package/src/widgets/actions/searcher/searcher-toggle.tsx +16 -8
  99. package/src/widgets/actions/searcher/searcher.test.tsx +22 -0
  100. package/src/widgets/actions/searcher/searcher.tsx +17 -7
  101. package/src/widgets/bar/download.test.tsx +5 -3
  102. package/src/widgets/bar/download.tsx +11 -35
  103. package/src/widgets/category/category-ui.tsx +26 -14
  104. package/src/widgets/category/category.tsx +37 -2
  105. package/src/widgets/category/components/category-legend.tsx +1 -2
  106. package/src/widgets/category/download.test.ts +4 -2
  107. package/src/widgets/category/download.ts +14 -27
  108. package/src/widgets/category/helpers.test.ts +17 -0
  109. package/src/widgets/category/helpers.ts +16 -0
  110. package/src/widgets/echart/style.ts +6 -0
  111. package/src/widgets/formula/download.test.tsx +3 -1
  112. package/src/widgets/formula/download.tsx +10 -30
  113. package/src/widgets/histogram/download.test.ts +6 -4
  114. package/src/widgets/histogram/download.ts +13 -26
  115. package/src/widgets/index.ts +1 -0
  116. package/src/widgets/markdown/download.test.ts +2 -2
  117. package/src/widgets/markdown/download.tsx +11 -26
  118. package/src/widgets/pie/download.test.ts +7 -3
  119. package/src/widgets/pie/download.ts +12 -25
  120. package/src/widgets/scatterplot/download.test.ts +4 -2
  121. package/src/widgets/scatterplot/download.ts +15 -28
  122. package/src/widgets/spread/download.test.ts +3 -1
  123. package/src/widgets/spread/download.ts +13 -27
  124. package/src/widgets/stores/index.ts +2 -0
  125. package/src/widgets/stores/widget-store-registry.test.ts +25 -0
  126. package/src/widgets/stores/widget-store-registry.ts +22 -0
  127. package/src/widgets/table/download.test.ts +5 -3
  128. package/src/widgets/table/download.ts +15 -29
  129. package/src/widgets/timeseries/download.test.ts +6 -4
  130. package/src/widgets/timeseries/download.ts +16 -29
  131. package/src/widgets/toolbox/toolbox.tsx +1 -1
  132. package/src/widgets/wrapper/style.ts +38 -2
  133. package/src/widgets/wrapper/widget-actions.tsx +2 -2
  134. package/src/widgets/wrapper/widget-wrapper.test.tsx +32 -2
  135. package/src/widgets/wrapper/widget-wrapper.tsx +62 -36
  136. package/dist/actions-KsX1xdDM.js +0 -1024
  137. package/dist/actions-KsX1xdDM.js.map +0 -1
  138. package/dist/category-CsdDqDzo.js +0 -833
  139. package/dist/category-CsdDqDzo.js.map +0 -1
  140. package/dist/download-Vlzn2jzb.js.map +0 -1
  141. package/dist/echart-DjcbtKgi.js.map +0 -1
  142. package/dist/stores-B8CHbgw-.js.map +0 -1
@@ -1,6 +1,6 @@
1
- import { useEffect, useRef } from 'react'
1
+ import { useEffect } from 'react'
2
2
  import { describe, it, expect, beforeEach, afterEach } from 'vitest'
3
- import { fireEvent, render, screen } from '@testing-library/react'
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
4
4
  import { Provider } from '../../provider/widget-provider'
5
5
  import { clearAllWidgetStores, getWidgetStore } from '../../stores'
6
6
  import { FullScreen } from './fullscreen'
@@ -61,22 +61,35 @@ describe('<FullScreen.Trigger> — store toggle', () => {
61
61
  })
62
62
 
63
63
  describe('<FullScreen.Slot> — render-prop body + portal', () => {
64
- it('passes the live isFullScreen flag to the render prop', () => {
65
- let received: boolean | undefined
64
+ it('passes false to the card copy and true to the dialog copy', () => {
66
65
  render(
67
66
  <Provider id='fs-rp' data={[]}>
68
67
  <FullScreen.Trigger />
69
68
  <FullScreen.Slot>
70
- {(isFullScreen) => {
71
- received = isFullScreen
72
- return <span data-testid='body'>body</span>
73
- }}
69
+ {(isFullScreen) => (
70
+ <span data-testid={isFullScreen ? 'dialog' : 'card'}>body</span>
71
+ )}
74
72
  </FullScreen.Slot>
75
73
  </Provider>,
76
74
  )
77
- expect(received).toBe(false)
75
+ expect(screen.getByTestId('card')).toBeTruthy()
76
+ expect(screen.queryByTestId('dialog')).toBeNull()
78
77
  fireEvent.click(screen.getByLabelText('Open in full screen'))
79
- expect(received).toBe(true)
78
+ expect(screen.getByTestId('card')).toBeTruthy()
79
+ expect(screen.getByTestId('dialog')).toBeTruthy()
80
+ })
81
+
82
+ it('renders plain children identically in both copies', () => {
83
+ render(
84
+ <Provider id='fs-plain' data={[]}>
85
+ <FullScreen.Trigger />
86
+ <FullScreen.Slot>
87
+ <span data-testid='body'>body</span>
88
+ </FullScreen.Slot>
89
+ </Provider>,
90
+ )
91
+ fireEvent.click(screen.getByLabelText('Open in full screen'))
92
+ expect(screen.getAllByTestId('body')).toHaveLength(2)
80
93
  })
81
94
 
82
95
  it('renders the title above the body when open', async () => {
@@ -166,47 +179,32 @@ describe('<FullScreen.Slot> — render-prop body + portal', () => {
166
179
  expect(css.maxWidth).toBe('921.6px')
167
180
  })
168
181
 
169
- it('keeps the same DOM node across the fullscreen toggle (portal reparent)', () => {
170
- render(
171
- <Provider id='fs-reparent' data={[]}>
172
- <FullScreen.Trigger />
173
- <FullScreen.Slot title='X'>
174
- {() => <span data-testid='inner'>inner</span>}
175
- </FullScreen.Slot>
176
- </Provider>,
177
- )
178
- const before = screen.getByTestId('inner')
179
- fireEvent.click(screen.getByLabelText('Open in full screen'))
180
- const after = screen.getByTestId('inner')
181
- expect(after).toBe(before)
182
- })
183
-
184
- it('mounts the body exactly once across multiple fullscreen toggles', () => {
185
- let mountCount = 0
182
+ it('keeps the card body mounted while the dialog mounts its own copy', async () => {
183
+ let mounts = 0
186
184
  function CountingChild() {
187
- const counted = useRef(false)
188
185
  useEffect(() => {
189
- if (counted.current) return
190
- counted.current = true
191
- mountCount += 1
186
+ mounts += 1
192
187
  }, [])
193
188
  return <div data-testid='counter'>x</div>
194
189
  }
195
- // Distinct labels for the trigger so its "close" aria-label can't collide
196
- // with the modal's own close button (also "Exit full screen" by default
197
- // when keepMounted=true keeps the modal in DOM).
198
190
  render(
199
- <Provider id='fs-mount-once' data={[]}>
191
+ <Provider id='fs-two-copies' data={[]}>
200
192
  <FullScreen.Trigger labels={{ open: 'OpenT', close: 'CloseT' }} />
201
- <FullScreen.Slot title='Z'>{() => <CountingChild />}</FullScreen.Slot>
193
+ <FullScreen.Slot title='Z'>
194
+ <CountingChild />
195
+ </FullScreen.Slot>
202
196
  </Provider>,
203
197
  )
204
- expect(mountCount).toBe(1)
198
+ const card = screen.getByTestId('counter')
199
+ expect(mounts).toBe(1)
205
200
  fireEvent.click(screen.getByLabelText('OpenT'))
206
- expect(mountCount).toBe(1)
201
+ expect(mounts).toBe(2)
202
+ expect(screen.getAllByTestId('counter')).toHaveLength(2)
203
+ expect(screen.getAllByTestId('counter')).toContain(card)
207
204
  fireEvent.click(screen.getByLabelText('CloseT'))
208
- expect(mountCount).toBe(1)
209
- fireEvent.click(screen.getByLabelText('OpenT'))
210
- expect(mountCount).toBe(1)
205
+ await waitFor(() =>
206
+ expect(screen.getAllByTestId('counter')).toHaveLength(1),
207
+ )
208
+ expect(screen.getByTestId('counter')).toBe(card)
211
209
  })
212
210
  })
@@ -1,11 +1,12 @@
1
+ import type { ECharts } from 'echarts'
1
2
  import {
2
3
  useCallback,
3
4
  useLayoutEffect,
5
+ useRef,
4
6
  useState,
5
7
  type ComponentType,
6
8
  type ReactNode,
7
9
  } from 'react'
8
- import { createPortal } from 'react-dom'
9
10
  import {
10
11
  Box,
11
12
  Dialog,
@@ -24,7 +25,19 @@ import { Fullscreen as FullscreenIcon } from '@mui/icons-material'
24
25
  import { FullscreenExit as FullscreenExitIcon } from '@mui/icons-material'
25
26
  import { Close as CloseIcon } from '@mui/icons-material'
26
27
  import { Tooltip } from '../../../components'
27
- import { getWidgetStore, useWidget, useWidgetId } from '../../stores'
28
+ import { Provider } from '../../provider/widget-provider'
29
+ import {
30
+ createWidgetStore,
31
+ getEchartInstance,
32
+ getWidgetStore,
33
+ hasWidgetStore,
34
+ setWidgetStoreEntry,
35
+ subscribeEchartInstance,
36
+ useWidget,
37
+ useWidgetId,
38
+ useWidgetShallow,
39
+ type WidgetState,
40
+ } from '../../stores'
28
41
  import { DEFAULT_FULLSCREEN_LABELS, type FullScreenLabels } from './labels'
29
42
  import type { FullScreenWidgetState } from './types'
30
43
  import { styles } from './style'
@@ -38,11 +51,18 @@ export interface FullScreenTriggerProps {
38
51
 
39
52
  export interface FullScreenSlotProps {
40
53
  /**
41
- * Render-prop body. Receives the live `isFullScreen` flag so the consumer
42
- * can branch per-mode props (e.g., `Widget.Toolbox visibleCount`) without
43
- * duplicating the JSX.
54
+ * The widget body. Rendered twice, identically: in the card, and again in
55
+ * the dialog while it's open. The components inside don't know which copy
56
+ * they are; the dialog's just runs on a widget store of its own — a copy
57
+ * of the card's taken on open, handed back to the card on close — so read
58
+ * the widget id with `useWidgetId()` inside the body rather than closing
59
+ * over the card's `id`.
60
+ *
61
+ * @deprecated The function form `(isFullScreen) => ReactNode` still works
62
+ * (`false` for the card, `true` for the dialog copy) but will be removed
63
+ * in the next major: pass the body as plain children.
44
64
  */
45
- children: (isFullScreen: boolean) => ReactNode
65
+ children: ReactNode | ((isFullScreen: boolean) => ReactNode)
46
66
  /** Header text rendered above the body when the modal is open. */
47
67
  title?: ReactNode
48
68
  /**
@@ -113,15 +133,42 @@ function FullScreenTrigger({
113
133
  )
114
134
  }
115
135
 
136
+ /** Id of the widget store the dialog copy runs on. */
137
+ export function fullScreenStoreId(id: string): string {
138
+ return `${id}::fullscreen`
139
+ }
140
+
141
+ // Fields a copy doesn't carry between the card's store and the dialog's:
142
+ // what the consumer feeds through Provider props (kept live on both), what
143
+ // the pipeline derives, the transforms (functions each copy's actions
144
+ // register in their own store) and the open flag itself.
145
+ const NOT_COPIED = new Set<string>([
146
+ 'rawData',
147
+ 'data',
148
+ 'dataTransforms',
149
+ 'configTransforms',
150
+ 'isLoading',
151
+ 'isFetching',
152
+ 'error',
153
+ 'rawFormatter',
154
+ 'labelFormatter',
155
+ 'isFullScreen',
156
+ ])
157
+
158
+ /** The UI state a copy carries: toggles, search text, sort, page… */
159
+ function copiedState(state: WidgetState): Partial<WidgetState> {
160
+ return Object.fromEntries(
161
+ Object.entries(state).filter(([key]) => !NOT_COPIED.has(key)),
162
+ ) as Partial<WidgetState>
163
+ }
164
+
116
165
  /**
117
- * Body wrapper. Renders its render-prop children once, via a single
118
- * `createPortal` call whose container is a stable `<div>` we hold as a ref.
119
- * That div is moved imperatively between an inline placeholder and the
120
- * `<DialogContent>` of an MUI `<Dialog keepMounted>`. Because both the
121
- * React parent (the portal call site) AND the portal container are
122
- * identical across the toggle, the subtree keeps its fiber identity and
123
- * ECharts (or any other heavy child) stays mounted while only its DOM
124
- * parent changes.
166
+ * Body wrapper. The card keeps its copy of the body while the dialog is
167
+ * open, and the dialog mounts a second one on a store of its own: a copy
168
+ * of the card's UI state taken on open, and handed back to the card when
169
+ * the dialog closes. While open the two don't affect each other. MUI
170
+ * mounts the dialog copy on open and unmounts it after the close
171
+ * transition, which drops its store.
125
172
  */
126
173
  function FullScreenSlot({
127
174
  children,
@@ -144,59 +191,34 @@ function FullScreenSlot({
144
191
  // margins — on a screen that small, fullscreen should mean fullscreen.
145
192
  const theme = useTheme()
146
193
  const fullBleed = useMediaQuery(theme.breakpoints.down('sm'))
147
- const [inlineEl, setInlineEl] = useState<HTMLElement | null>(null)
148
- const [dialogEl, setDialogEl] = useState<HTMLElement | null>(null)
149
- // Stable, never-changing portal container created once per slot. Held in
150
- // state (not a ref) so the React Compiler is happy reading it during
151
- // render — it's immutable from React's perspective, only its DOM parent
152
- // changes via `appendChild`. Switching `createPortal`'s `container`
153
- // argument makes React unmount and remount the children, defeating the
154
- // single-mount goal; keeping the container identical and moving the div
155
- // imperatively avoids that.
156
- const [portalDiv] = useState<HTMLDivElement | null>(() => {
157
- if (typeof document === 'undefined') return null
158
- const div = document.createElement('div')
159
- // `display: contents` keeps the wrapper transparent in flow so it
160
- // doesn't introduce a stray block when nested inside the inline slot.
161
- div.style.display = 'contents'
162
- return div
163
- })
164
-
165
194
  const handleClose = useCallback(() => {
166
195
  getWidgetStore(id).setState({
167
196
  isFullScreen: false,
168
197
  } as Partial<FullScreenWidgetState>)
169
198
  }, [id])
170
199
 
171
- // Reparent the portal div into the active host whenever `open` (or either
172
- // host) changes. `useLayoutEffect` runs synchronously after commit (after
173
- // refs have been written), so the dialog ref is already live when we read
174
- // it.
175
- //
176
- // Anti-blink: pin a `min-height` on the inline shell that's always live —
177
- // not just while fullscreen is open. We re-measure whenever the body is
178
- // inline (open=false) so the lock tracks data-driven height changes
179
- // (Formula adding rows, Table growing, ECharts re-init…) and survives
180
- // every fullscreen toggle. Briefly clearing minHeight before the measure
181
- // lets the lock shrink as well as grow; both operations are synchronous
182
- // within useLayoutEffect, so the browser never paints the intermediate
183
- // "no lock" frame.
200
+ // Closing hands the dialog's state back to the card. Keyed on the flag,
201
+ // not on the close button, so every way out (Escape, backdrop, a Trigger)
202
+ // counts. The dialog copy is still mounted here — it unmounts after the
203
+ // transition — so its store still exists.
204
+ const wasOpen = useRef(open)
184
205
  useLayoutEffect(() => {
185
- if (!portalDiv || !inlineEl) return
186
- const host = open ? dialogEl : inlineEl
187
- if (host && portalDiv.parentNode !== host) {
188
- host.appendChild(portalDiv)
206
+ const dialogId = fullScreenStoreId(id)
207
+ if (wasOpen.current && !open && hasWidgetStore(dialogId)) {
208
+ getWidgetStore(id).setState(
209
+ copiedState(getWidgetStore(dialogId).getState()),
210
+ )
189
211
  }
190
- if (!open) refreshInlineHeightLock(inlineEl)
191
- }, [open, inlineEl, dialogEl, portalDiv])
212
+ wasOpen.current = open
213
+ }, [id, open])
192
214
 
193
215
  const titleId = `widget-fullscreen-title-${id}`
194
- const rendered = children(open)
216
+ const render = (inDialog: boolean) =>
217
+ typeof children === 'function' ? children(inDialog) : children
195
218
 
196
219
  return (
197
220
  <>
198
- <Box ref={setInlineEl} sx={styles.inlineSlot} />
199
- {portalDiv ? createPortal(rendered, portalDiv) : null}
221
+ <Box sx={styles.inlineSlot}>{render(false)}</Box>
200
222
  <Dialog
201
223
  open={open}
202
224
  onClose={handleClose}
@@ -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
- // This used to sit at `tooltip + 1` to cover a Toolbox popper left
218
- // open when fullscreen opened. That's the Toolbox's own job and it
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
- <DialogTitle id={titleId} component='div' sx={styles.dialogTitle}>
226
- <Typography
227
- variant='subtitle1'
228
- component='span'
229
- sx={styles.titleText}
230
- >
231
- {title}
232
- </Typography>
233
- <Box sx={styles.dialogActions}>
234
- {actions}
235
- <Tooltip title={_labels.close}>
236
- <IconButton
237
- size='small'
238
- aria-label={_labels.close}
239
- onClick={handleClose}
240
- >
241
- <CloseSvg fontSize='small' {...iconProps} />
242
- </IconButton>
243
- </Tooltip>
244
- </Box>
245
- </DialogTitle>
246
- <DialogContent ref={setDialogEl} sx={styles.dialogContent} />
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
- * {(isFullScreen) => <Body visibleCount={isFullScreen ? undefined : 2} />}
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 `name` field. Keeps the dataset shape (one inner array per series).
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(input: unknown, search: string): unknown {
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
- const name = rawName == null ? '' : stringifyPrimitive(rawName)
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
- id,
46
- (s) => (s.transformStates.searcher?.searchText as string | undefined) ?? '',
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 useCallback so identity
50
- // only changes when text changes, allowing useTransform to re-register.
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
  })