@carto/ps-react-ui 5.1.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/dist/actions-Dhlz_adK.js +1167 -0
  2. package/dist/actions-Dhlz_adK.js.map +1 -0
  3. package/dist/category-D01ruG-l.js +842 -0
  4. package/dist/category-D01ruG-l.js.map +1 -0
  5. package/dist/{download-DdaK3qgC.js → download-DGgFOaXj.js} +81 -80
  6. package/dist/download-DGgFOaXj.js.map +1 -0
  7. package/dist/{echart-CRihbmRv.js → echart-CwzbPElc.js} +3 -2
  8. package/dist/echart-CwzbPElc.js.map +1 -0
  9. package/dist/{formula-noJzQwKo.js → formula-BIxJr1P3.js} +6 -6
  10. package/dist/{formula-noJzQwKo.js.map → formula-BIxJr1P3.js.map} +1 -1
  11. package/dist/legend.js +34 -21
  12. package/dist/legend.js.map +1 -1
  13. package/dist/{markdown-2dfL6Cmb.js → markdown-oo9mnJun.js} +6 -6
  14. package/dist/{markdown-2dfL6Cmb.js.map → markdown-oo9mnJun.js.map} +1 -1
  15. package/dist/{spread-Ccpg9fi0.js → spread-CgCTKPQD.js} +7 -7
  16. package/dist/{spread-Ccpg9fi0.js.map → spread-CgCTKPQD.js.map} +1 -1
  17. package/dist/{table-Bx9Uj3U0.js → table-lb5momun.js} +3 -3
  18. package/dist/{table-Bx9Uj3U0.js.map → table-lb5momun.js.map} +1 -1
  19. package/dist/types/legend/stores/types.d.ts +4 -2
  20. package/dist/types/widgets-v2/actions/download/csv-item.d.ts +3 -3
  21. package/dist/types/widgets-v2/actions/download/index.d.ts +1 -1
  22. package/dist/types/widgets-v2/actions/download/png-item.d.ts +2 -2
  23. package/dist/types/widgets-v2/actions/download/types.d.ts +12 -1
  24. package/dist/types/widgets-v2/actions/fullscreen/fullscreen.d.ts +20 -13
  25. package/dist/types/widgets-v2/actions/index.d.ts +1 -1
  26. package/dist/types/widgets-v2/actions/searcher/filter.d.ts +3 -2
  27. package/dist/types/widgets-v2/bar/download.d.ts +3 -3
  28. package/dist/types/widgets-v2/category/category-ui.d.ts +9 -1
  29. package/dist/types/widgets-v2/category/download.d.ts +3 -3
  30. package/dist/types/widgets-v2/echart/style.d.ts +1 -0
  31. package/dist/types/widgets-v2/formula/download.d.ts +3 -3
  32. package/dist/types/widgets-v2/histogram/download.d.ts +3 -3
  33. package/dist/types/widgets-v2/index.d.ts +1 -1
  34. package/dist/types/widgets-v2/markdown/download.d.ts +3 -3
  35. package/dist/types/widgets-v2/pie/download.d.ts +3 -3
  36. package/dist/types/widgets-v2/scatterplot/download.d.ts +3 -3
  37. package/dist/types/widgets-v2/spread/download.d.ts +3 -3
  38. package/dist/types/widgets-v2/table/download.d.ts +3 -3
  39. package/dist/types/widgets-v2/timeseries/download.d.ts +3 -3
  40. package/dist/types/widgets-v2/toolbox/toolbox.d.ts +1 -1
  41. package/dist/types/widgets-v2/wrapper/style.d.ts +31 -2
  42. package/dist/types/widgets-v2/wrapper/widget-actions.d.ts +2 -2
  43. package/dist/types/widgets-v2/wrapper/widget-wrapper.d.ts +2 -2
  44. package/dist/widgets-v2/actions.js +2 -2
  45. package/dist/widgets-v2/bar.js +7 -7
  46. package/dist/widgets-v2/bar.js.map +1 -1
  47. package/dist/widgets-v2/category.js +1 -1
  48. package/dist/widgets-v2/echart.js +1 -1
  49. package/dist/widgets-v2/formula.js +1 -1
  50. package/dist/widgets-v2/histogram.js +10 -10
  51. package/dist/widgets-v2/histogram.js.map +1 -1
  52. package/dist/widgets-v2/markdown.js +1 -1
  53. package/dist/widgets-v2/pie.js +10 -10
  54. package/dist/widgets-v2/pie.js.map +1 -1
  55. package/dist/widgets-v2/scatterplot.js +9 -9
  56. package/dist/widgets-v2/scatterplot.js.map +1 -1
  57. package/dist/widgets-v2/spread.js +1 -1
  58. package/dist/widgets-v2/table.js +1 -1
  59. package/dist/widgets-v2/timeseries.js +12 -12
  60. package/dist/widgets-v2/timeseries.js.map +1 -1
  61. package/dist/widgets-v2.js +364 -376
  62. package/dist/widgets-v2.js.map +1 -1
  63. package/package.json +3 -3
  64. package/src/legend/components/legend-item/legend-item-ui.test.tsx +22 -1
  65. package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +22 -6
  66. package/src/legend/stores/types.ts +4 -2
  67. package/src/widgets-v2/actions/download/csv-item.test.tsx +3 -3
  68. package/src/widgets-v2/actions/download/csv-item.tsx +6 -6
  69. package/src/widgets-v2/actions/download/download.tsx +5 -3
  70. package/src/widgets-v2/actions/download/index.ts +1 -1
  71. package/src/widgets-v2/actions/download/png-item.test.tsx +5 -3
  72. package/src/widgets-v2/actions/download/png-item.tsx +4 -4
  73. package/src/widgets-v2/actions/download/types.ts +15 -1
  74. package/src/widgets-v2/actions/fullscreen/fullscreen-store.test.tsx +155 -0
  75. package/src/widgets-v2/actions/fullscreen/fullscreen.test.tsx +39 -41
  76. package/src/widgets-v2/actions/fullscreen/fullscreen.tsx +211 -96
  77. package/src/widgets-v2/actions/index.ts +1 -0
  78. package/src/widgets-v2/actions/searcher/filter.test.ts +28 -0
  79. package/src/widgets-v2/actions/searcher/filter.ts +22 -4
  80. package/src/widgets-v2/actions/searcher/searcher-toggle.tsx +9 -4
  81. package/src/widgets-v2/actions/searcher/searcher.test.tsx +22 -0
  82. package/src/widgets-v2/actions/searcher/searcher.tsx +16 -0
  83. package/src/widgets-v2/bar/download.test.tsx +5 -3
  84. package/src/widgets-v2/bar/download.tsx +5 -4
  85. package/src/widgets-v2/category/category-ui.tsx +24 -13
  86. package/src/widgets-v2/category/category.tsx +37 -2
  87. package/src/widgets-v2/category/download.test.ts +4 -2
  88. package/src/widgets-v2/category/download.ts +5 -4
  89. package/src/widgets-v2/echart/style.ts +6 -0
  90. package/src/widgets-v2/formula/download.test.tsx +3 -1
  91. package/src/widgets-v2/formula/download.tsx +5 -4
  92. package/src/widgets-v2/histogram/download.test.ts +6 -4
  93. package/src/widgets-v2/histogram/download.ts +5 -4
  94. package/src/widgets-v2/index.ts +1 -0
  95. package/src/widgets-v2/markdown/download.test.ts +2 -2
  96. package/src/widgets-v2/markdown/download.tsx +5 -4
  97. package/src/widgets-v2/pie/download.test.ts +7 -3
  98. package/src/widgets-v2/pie/download.ts +5 -4
  99. package/src/widgets-v2/scatterplot/download.test.ts +4 -2
  100. package/src/widgets-v2/scatterplot/download.ts +5 -4
  101. package/src/widgets-v2/spread/download.test.ts +3 -1
  102. package/src/widgets-v2/spread/download.ts +5 -4
  103. package/src/widgets-v2/table/download.test.ts +5 -3
  104. package/src/widgets-v2/table/download.ts +4 -3
  105. package/src/widgets-v2/timeseries/download.test.ts +6 -4
  106. package/src/widgets-v2/timeseries/download.ts +5 -4
  107. package/src/widgets-v2/toolbox/toolbox.tsx +1 -1
  108. package/src/widgets-v2/wrapper/style.ts +38 -2
  109. package/src/widgets-v2/wrapper/widget-actions.tsx +2 -2
  110. package/src/widgets-v2/wrapper/widget-wrapper.test.tsx +32 -2
  111. package/src/widgets-v2/wrapper/widget-wrapper.tsx +58 -32
  112. package/dist/actions-BjPfCLG9.js +0 -1024
  113. package/dist/actions-BjPfCLG9.js.map +0 -1
  114. package/dist/category-DGUDabsi.js +0 -829
  115. package/dist/category-DGUDabsi.js.map +0 -1
  116. package/dist/download-DdaK3qgC.js.map +0 -1
  117. package/dist/echart-CRihbmRv.js.map +0 -1
@@ -96,7 +96,8 @@ export interface LegendRampVariable {
96
96
  * Ordered low→high stops. Discrete ramps **require `colors.length + 1`**
97
97
  * stops — the boundaries between color steps, including both outer edges —
98
98
  * and each label is centered on its boundary line (keep labels short, e.g.
99
- * `20K`; there is no collision handling). A discrete ramp with any other
99
+ * `20K`; there is no collision handling; pass `''` to hide one — each step's
100
+ * tooltip still shows its full range). A discrete ramp with any other
100
101
  * stop count is incoherent and renders an inline error instead of the bar.
101
102
  * Continuous ramps show only the first/last endpoints.
102
103
  */
@@ -110,7 +111,8 @@ export interface LegendRampVariable {
110
111
  */
111
112
  avg?: number
112
113
  /**
113
- * Formats `avg` for its tooltip. Default `String(avg)` — pass e.g.
114
+ * Formats `avg` and, on discrete ramps, the stop values shown in each
115
+ * step's range tooltip. Default `String(value)` — pass e.g.
114
116
  * `(v) => formatNumber(v, 'es-ES')` or `(v) => `${v}%``. Use a stable
115
117
  * reference (same contract as the other variable fields).
116
118
  */
@@ -47,7 +47,7 @@ describe('buildCsvDownloadItem', () => {
47
47
  ['a', 1],
48
48
  ],
49
49
  })
50
- const handle = await item.resolve()
50
+ const handle = await item.resolve({ id: null })
51
51
  expect(handle.url).toBe('blob:csv')
52
52
  expect(handle.filename).toBe('sales.csv')
53
53
  expect(csvText).toBe('name,value\na,1')
@@ -60,7 +60,7 @@ describe('buildCsvDownloadItem', () => {
60
60
  filename: 'sales',
61
61
  getRows: () => [['=SUM(A1)']],
62
62
  })
63
- await item.resolve()
63
+ await item.resolve({ id: null })
64
64
  expect(csvText).toBe("'=SUM(A1)")
65
65
  })
66
66
 
@@ -69,7 +69,7 @@ describe('buildCsvDownloadItem', () => {
69
69
  filename: 'table',
70
70
  getCsv: () => 'col\n=raw',
71
71
  })
72
- const handle = await item.resolve()
72
+ const handle = await item.resolve({ id: null })
73
73
  expect(handle.filename).toBe('table.csv')
74
74
  // getCsv is an escape hatch — content is used as-is, no extra escaping.
75
75
  expect(csvText).toBe('col\n=raw')
@@ -1,7 +1,7 @@
1
1
  import { DOWNLOAD_ITEM_IDS } from './constants'
2
2
  import { downloadToCSV } from './exports'
3
3
  import { CSVIcon } from './icons'
4
- import type { DownloadItem } from './types'
4
+ import type { DownloadContext, DownloadItem } from './types'
5
5
 
6
6
  interface BuildCsvDownloadItemBase {
7
7
  /** Base filename (without extension). The item appends `.csv`. */
@@ -23,7 +23,7 @@ export type BuildCsvDownloadItemArgs =
23
23
  * through `toCsvString` so escaping (incl. the spreadsheet
24
24
  * formula-injection guard) stays consistent across widgets.
25
25
  */
26
- getRows: () => readonly (readonly unknown[])[]
26
+ getRows: (ctx: DownloadContext) => readonly (readonly unknown[])[]
27
27
  getCsv?: never
28
28
  })
29
29
  | (BuildCsvDownloadItemBase & {
@@ -32,7 +32,7 @@ export type BuildCsvDownloadItemArgs =
32
32
  * (e.g. Table) that already serialise their own CSV with bespoke
33
33
  * header/cell handling.
34
34
  */
35
- getCsv: () => string
35
+ getCsv: (ctx: DownloadContext) => string
36
36
  getRows?: never
37
37
  })
38
38
 
@@ -49,9 +49,9 @@ export function buildCsvDownloadItem(
49
49
  id: DOWNLOAD_ITEM_IDS.csv,
50
50
  label: args.label ?? 'CSV',
51
51
  icon: <CSVIcon fontSize='small' />,
52
- resolve: () => {
52
+ resolve: (ctx) => {
53
53
  if (args.getCsv) {
54
- const csv = args.getCsv()
54
+ const csv = args.getCsv(ctx)
55
55
  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
56
56
  const url = URL.createObjectURL(blob)
57
57
  return Promise.resolve({
@@ -60,7 +60,7 @@ export function buildCsvDownloadItem(
60
60
  revoke: () => URL.revokeObjectURL(url),
61
61
  })
62
62
  }
63
- const handle = downloadToCSV(args.getRows())
63
+ const handle = downloadToCSV(args.getRows(ctx))
64
64
  return Promise.resolve({
65
65
  url: handle.url,
66
66
  filename: `${args.filename}.csv`,
@@ -1,4 +1,4 @@
1
- import { useCallback, useState, type ComponentType } from 'react'
1
+ import { useCallback, useContext, useState, type ComponentType } from 'react'
2
2
  import {
3
3
  CircularProgress,
4
4
  IconButton,
@@ -10,6 +10,7 @@ import {
10
10
  } from '@mui/material'
11
11
  import { FileDownload as DownloadIcon } from '@mui/icons-material'
12
12
  import { Tooltip } from '../../../components'
13
+ import { WidgetContext } from '../../stores'
13
14
  import { triggerLinkDownload } from './exports'
14
15
  import type { DownloadItem } from './types'
15
16
  import { DEFAULT_DOWNLOAD_LABELS, type DownloadLabels } from './labels'
@@ -37,6 +38,7 @@ export function Download({
37
38
  }: DownloadProps) {
38
39
  const _labels = { ...DEFAULT_DOWNLOAD_LABELS, ...labels }
39
40
  const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
41
+ const id = useContext(WidgetContext)
40
42
  const [isDownloading, setIsDownloading] = useState(false)
41
43
  const [error, setError] = useState<Error | null>(null)
42
44
 
@@ -59,7 +61,7 @@ export function Download({
59
61
  setIsDownloading(true)
60
62
  setError(null)
61
63
  item
62
- .resolve()
64
+ .resolve({ id })
63
65
  .then(({ url, filename, revoke }) => {
64
66
  // `item.filename` overrides what `resolve()` returns — lets consumers
65
67
  // template the filename with widget metadata declaratively.
@@ -78,7 +80,7 @@ export function Download({
78
80
  setIsDownloading(false)
79
81
  })
80
82
  },
81
- [close, isDownloading, onError],
83
+ [close, id, isDownloading, onError],
82
84
  )
83
85
 
84
86
  const triggerLabel = isDownloading
@@ -11,5 +11,5 @@ export { CSVIcon, PNGIcon, MarkdownIcon } from './icons'
11
11
  export { buildPngDownloadItem, type BuildPngDownloadItemArgs } from './png-item'
12
12
  export { buildCsvDownloadItem, type BuildCsvDownloadItemArgs } from './csv-item'
13
13
  export { DOWNLOAD_ITEM_IDS, type DownloadItemId } from './constants'
14
- export type { DownloadItem } from './types'
14
+ export type { DownloadItem, DownloadContext } from './types'
15
15
  export { DEFAULT_DOWNLOAD_LABELS, type DownloadLabels } from './labels'
@@ -44,7 +44,7 @@ describe('buildPngDownloadItem', () => {
44
44
  filename: 'sales',
45
45
  getCaptureEl: () => document.createElement('div'),
46
46
  })
47
- const handle = await item.resolve()
47
+ const handle = await item.resolve({ id: null })
48
48
  expect(handle.url).toBe('blob:png')
49
49
  expect(handle.filename).toBe('sales.png')
50
50
  handle.revoke?.()
@@ -56,7 +56,9 @@ describe('buildPngDownloadItem', () => {
56
56
  filename: 'demo',
57
57
  getCaptureEl: () => null,
58
58
  })
59
- await expect(item.resolve()).rejects.toThrow(/No PNG capture element/)
59
+ await expect(item.resolve({ id: null })).rejects.toThrow(
60
+ /No PNG capture element/,
61
+ )
60
62
  })
61
63
 
62
64
  it('forwards pixelRatio and backgroundColor overrides', async () => {
@@ -66,7 +68,7 @@ describe('buildPngDownloadItem', () => {
66
68
  pixelRatio: 4,
67
69
  backgroundColor: '#fff',
68
70
  })
69
- const handle = await item.resolve()
71
+ const handle = await item.resolve({ id: null })
70
72
  expect(handle.filename).toBe('sales.png')
71
73
  })
72
74
  })
@@ -1,7 +1,7 @@
1
1
  import { DOWNLOAD_ITEM_IDS } from './constants'
2
2
  import { downloadDOMToPNG } from './exports'
3
3
  import { PNGIcon } from './icons'
4
- import type { DownloadItem } from './types'
4
+ import type { DownloadContext, DownloadItem } from './types'
5
5
 
6
6
  export interface BuildPngDownloadItemArgs {
7
7
  /** Base filename (without extension). The item appends `.png`. */
@@ -11,7 +11,7 @@ export interface BuildPngDownloadItemArgs {
11
11
  * download config doesn't capture a stale reference. Wire it to
12
12
  * `() => getCaptureEl(id)` from `widgets-v2/stores`.
13
13
  */
14
- getCaptureEl: () => HTMLElement | null
14
+ getCaptureEl: (ctx: DownloadContext) => HTMLElement | null
15
15
  /** html2canvas `scale`. Default 2. */
16
16
  pixelRatio?: number
17
17
  /** html2canvas `backgroundColor`. Default transparent (`null`). */
@@ -33,8 +33,8 @@ export function buildPngDownloadItem(
33
33
  id: DOWNLOAD_ITEM_IDS.png,
34
34
  label: args.label ?? 'PNG',
35
35
  icon: <PNGIcon fontSize='small' />,
36
- resolve: async () => {
37
- const el = args.getCaptureEl()
36
+ resolve: async (ctx) => {
37
+ const el = args.getCaptureEl(ctx)
38
38
  if (!el) {
39
39
  throw new Error('[widgets-v2] No PNG capture element available')
40
40
  }
@@ -1,5 +1,17 @@
1
1
  import type { ReactElement } from 'react'
2
2
 
3
+ /**
4
+ * Passed to {@link DownloadItem.resolve} by `<Widget.Download>`.
5
+ */
6
+ export interface DownloadContext {
7
+ /**
8
+ * Id of the widget store the menu was opened from — the dialog's own
9
+ * store when opened from `FullScreen.Slot`'s header, so an export matches
10
+ * what the user is looking at. `null` outside a `<Widget.Provider>`.
11
+ */
12
+ id: string | null
13
+ }
14
+
3
15
  export interface DownloadItem {
4
16
  /** Unique key (also used as menu label key). */
5
17
  id: string
@@ -28,5 +40,7 @@ export interface DownloadItem {
28
40
  * Resolves to a Blob URL the trigger will click. Optional `revoke` is
29
41
  * called after the click is dispatched.
30
42
  */
31
- resolve: () => Promise<{ url: string; filename: string; revoke?: () => void }>
43
+ resolve: (
44
+ ctx: DownloadContext,
45
+ ) => Promise<{ url: string; filename: string; revoke?: () => void }>
32
46
  }
@@ -0,0 +1,155 @@
1
+ import { describe, it, expect, afterEach, vi } from 'vitest'
2
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
3
+ import { Provider } from '../../provider/widget-provider'
4
+ import type { ECharts } from 'echarts'
5
+ import {
6
+ clearAllWidgetStores,
7
+ getWidgetStore,
8
+ hasWidgetStore,
9
+ setEchartInstance,
10
+ useWidget,
11
+ useWidgetId,
12
+ } from '../../stores'
13
+ import { Download } from '../download/download'
14
+ import { FullScreen, fullScreenStoreId } from './fullscreen'
15
+
16
+ afterEach(() => clearAllWidgetStores())
17
+
18
+ // A stand-in for any action: reads and writes one flag on whichever store
19
+ // its copy runs on.
20
+ function Flag() {
21
+ const id = useWidgetId()
22
+ const on = useWidget(id, (s) => s.transformStates.flag?.enabled ?? false)
23
+ return (
24
+ <button
25
+ type='button'
26
+ data-testid={`flag-${id}`}
27
+ aria-pressed={on}
28
+ onClick={() =>
29
+ getWidgetStore(id).setState((s) => ({
30
+ transformStates: { ...s.transformStates, flag: { enabled: !on } },
31
+ }))
32
+ }
33
+ >
34
+ flag
35
+ </button>
36
+ )
37
+ }
38
+
39
+ function DataProbe() {
40
+ const id = useWidgetId()
41
+ const data = useWidget(id, (s) => s.rawData)
42
+ return <span data-testid={`data-${id}`}>{String(data)}</span>
43
+ }
44
+
45
+ function Widget({ data = 'a' }: { data?: string }) {
46
+ return (
47
+ <Provider id='w' data={data}>
48
+ <FullScreen.Trigger labels={{ open: 'OpenT', close: 'CloseT' }} />
49
+ <FullScreen.Slot>
50
+ <Flag />
51
+ <DataProbe />
52
+ </FullScreen.Slot>
53
+ </Provider>
54
+ )
55
+ }
56
+
57
+ const dialogId = fullScreenStoreId('w')
58
+ const flag = (id: string) =>
59
+ screen.getByTestId(`flag-${id}`).getAttribute('aria-pressed')
60
+
61
+ async function close() {
62
+ fireEvent.click(screen.getByLabelText('CloseT'))
63
+ await waitFor(() =>
64
+ expect(screen.queryByTestId(`flag-${dialogId}`)).toBeNull(),
65
+ )
66
+ }
67
+
68
+ describe('<FullScreen.Slot> — the dialog runs on its own store', () => {
69
+ it('starts from the card, runs apart while open, and hands back on close', async () => {
70
+ render(<Widget />)
71
+ fireEvent.click(screen.getByTestId('flag-w'))
72
+ expect(flag('w')).toBe('true')
73
+
74
+ fireEvent.click(screen.getByLabelText('OpenT'))
75
+ // Seeded from the card.
76
+ expect(flag(dialogId)).toBe('true')
77
+
78
+ // Changes in the dialog don't reach the card while open…
79
+ fireEvent.click(screen.getByTestId(`flag-${dialogId}`))
80
+ expect(flag(dialogId)).toBe('false')
81
+ expect(flag('w')).toBe('true')
82
+
83
+ // …and do once it closes. The dialog's store goes with it.
84
+ await close()
85
+ expect(flag('w')).toBe('false')
86
+ expect(hasWidgetStore(dialogId)).toBe(false)
87
+ })
88
+
89
+ it('keeps the consumer props flowing into the dialog while it is open', () => {
90
+ const { rerender } = render(<Widget data='a' />)
91
+ fireEvent.click(screen.getByLabelText('OpenT'))
92
+ expect(screen.getByTestId(`data-${dialogId}`).textContent).toBe('a')
93
+ rerender(<Widget data='b' />)
94
+ expect(screen.getByTestId(`data-${dialogId}`).textContent).toBe('b')
95
+ })
96
+
97
+ it('reopening starts from the card again, not from the last dialog', async () => {
98
+ render(<Widget />)
99
+ fireEvent.click(screen.getByLabelText('OpenT'))
100
+ await close()
101
+ act(() =>
102
+ getWidgetStore('w').setState({
103
+ transformStates: { flag: { enabled: true } },
104
+ }),
105
+ )
106
+ fireEvent.click(screen.getByLabelText('OpenT'))
107
+ expect(flag(dialogId)).toBe('true')
108
+ })
109
+
110
+ it('downloads from the dialog header resolve against the dialog store', async () => {
111
+ const resolve = vi.fn(() =>
112
+ Promise.resolve({ url: 'blob:x', filename: 'x.csv' }),
113
+ )
114
+ render(
115
+ <Provider id='w' data='a'>
116
+ <FullScreen.Trigger labels={{ open: 'OpenT', close: 'CloseT' }} />
117
+ <FullScreen.Slot
118
+ actions={<Download items={[{ id: 'csv', label: 'CSV', resolve }]} />}
119
+ >
120
+ <span>body</span>
121
+ </FullScreen.Slot>
122
+ </Provider>,
123
+ )
124
+ fireEvent.click(screen.getByLabelText('OpenT'))
125
+ fireEvent.click(screen.getByLabelText('Download'))
126
+ fireEvent.click(await screen.findByText('CSV'))
127
+ expect(resolve).toHaveBeenCalledWith({ id: dialogId })
128
+ })
129
+
130
+ // Legend / dataZoom / brush live in the chart instance, not the store.
131
+ // The Slot moves them through the instance registry, so Widget.Echart
132
+ // doesn't need to know about fullscreen.
133
+ it('starts the dialog chart from the card chart and hands it back on close', async () => {
134
+ const fakeChart = (option: object) =>
135
+ ({
136
+ getOption: vi.fn(() => option),
137
+ setOption: vi.fn(),
138
+ dispatchAction: vi.fn(),
139
+ isDisposed: () => false,
140
+ }) as unknown as ECharts & {
141
+ setOption: ReturnType<typeof vi.fn>
142
+ }
143
+ const card = fakeChart({ from: 'card' })
144
+ const dialog = fakeChart({ from: 'dialog' })
145
+ render(<Widget />)
146
+ act(() => setEchartInstance('w', card))
147
+
148
+ fireEvent.click(screen.getByLabelText('OpenT'))
149
+ act(() => setEchartInstance(dialogId, dialog))
150
+ expect(dialog.setOption).toHaveBeenCalledWith({ from: 'card' })
151
+
152
+ await close()
153
+ expect(card.setOption).toHaveBeenCalledWith({ from: 'dialog' })
154
+ })
155
+ })
@@ -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
  })