diff --git a/.changeset/png-export-options.md b/.changeset/png-export-options.md new file mode 100644 index 00000000..2e36052a --- /dev/null +++ b/.changeset/png-export-options.md @@ -0,0 +1,5 @@ +--- +'@embeddable.com/remarkable-pro': minor +--- + +Chart menu options can now carry an `options` object, passed to `onClick` as `options`. PNG export supports `options.backgroundColor` to override the default transparent background. diff --git a/src/components/charts/shared/ChartCard/ChartCardMenuPro/ChartCardMenuPro.test.tsx b/src/components/charts/shared/ChartCard/ChartCardMenuPro/ChartCardMenuPro.test.tsx index 38cf2d3f..aff0abab 100644 --- a/src/components/charts/shared/ChartCard/ChartCardMenuPro/ChartCardMenuPro.test.tsx +++ b/src/components/charts/shared/ChartCard/ChartCardMenuPro/ChartCardMenuPro.test.tsx @@ -120,6 +120,58 @@ describe('ChartCardMenuPro', () => { vi.useRealTimers(); }); + it('passes the option options to onClick', async () => { + vi.useFakeTimers(); + const onClickMock = vi.fn(); + + render( + , + ); + fireEvent.click(screen.getByText('export.png')); + await vi.runAllTimersAsync(); + + expect(onClickMock).toHaveBeenCalledWith( + expect.objectContaining({ options: { backgroundColor: '#fff' } }), + ); + vi.useRealTimers(); + }); + + it('passes the option options to onClick when using onCustomDownload', async () => { + vi.useFakeTimers(); + const onClickMock = vi.fn(); + const onCustomDownload = vi.fn((cb) => cb({ title: 'custom' })); + + render( + , + ); + fireEvent.click(screen.getByText('export.png')); + await vi.runAllTimersAsync(); + + expect(onClickMock).toHaveBeenCalledWith( + expect.objectContaining({ title: 'custom', options: { backgroundColor: '#fff' } }), + ); + vi.useRealTimers(); + }); + it('calls onCustomDownload instead of onClick directly when provided', async () => { vi.useFakeTimers(); const onClickMock = vi.fn().mockResolvedValue(undefined); diff --git a/src/components/charts/shared/ChartCard/ChartCardMenuPro/ChartCardMenuPro.tsx b/src/components/charts/shared/ChartCard/ChartCardMenuPro/ChartCardMenuPro.tsx index 3dbedd27..ea414906 100644 --- a/src/components/charts/shared/ChartCard/ChartCardMenuPro/ChartCardMenuPro.tsx +++ b/src/components/charts/shared/ChartCard/ChartCardMenuPro/ChartCardMenuPro.tsx @@ -50,16 +50,19 @@ export const ChartCardMenuPro: React.FC = (props) => { }; const handleOptionClick = (option: ChartCardMenuOption) => { + const run = (args: ChartCardMenuOptionOnClickProps) => + option.onClick({ ...args, options: option.options }); + if (option.isInstantAction) { - option.onClick({ ...props, theme }); + run({ ...props, theme }); return; } setIsLoading(true); if (props.onCustomDownload) { - props.onCustomDownload((args) => startAction(() => option.onClick(args))); + props.onCustomDownload((args) => startAction(() => run(args))); return; } - startAction(() => option.onClick({ ...props, theme })); + startAction(() => run({ ...props, theme })); }; return ( diff --git a/src/theme/defaults/defaults.ChartCardMenu.constants.ts b/src/theme/defaults/defaults.ChartCardMenu.constants.ts index 96fc0344..c17eab9e 100644 --- a/src/theme/defaults/defaults.ChartCardMenu.constants.ts +++ b/src/theme/defaults/defaults.ChartCardMenu.constants.ts @@ -12,6 +12,8 @@ export type ChartCardMenuOptionOnClickProps = { dimensionsAndMeasures?: (Dimension | Measure)[]; containerRef?: React.RefObject; theme: Theme; + /** Per-option settings supplied by the theme (see ChartCardMenuOption.options). */ + options?: Record; onCustomDownload?: (props: (props: ChartCardMenuOptionOnClickProps) => void) => void; }; @@ -21,6 +23,8 @@ export type ChartCardMenuOption = { iconSrc?: string; /** Instant actions run immediately: no loading state and no onCustomDownload interception. */ isInstantAction?: boolean; + /** Settings passed to onClick as `options`. PNG supports `backgroundColor`. */ + options?: Record; onClick: (props: ChartCardMenuOptionOnClickProps) => void; }; diff --git a/src/theme/utils/export.utils.test.ts b/src/theme/utils/export.utils.test.ts index 5f9b88d8..eeb5e15a 100644 --- a/src/theme/utils/export.utils.test.ts +++ b/src/theme/utils/export.utils.test.ts @@ -212,6 +212,32 @@ describe('exportPNG', () => { expect(domtoimage.toPng).toHaveBeenCalledWith(el, expect.objectContaining({ cacheBust: true })); }); + it('does not set a background color by default', async () => { + const el = document.createElement('div'); + (domtoimage.toPng as Mock).mockResolvedValue('data:image/png;base64,abc'); + + await exportPNG({ title: 'test', containerRef: { current: el }, theme: mockTheme }); + + expect((domtoimage.toPng as Mock).mock.calls[0]![1]).not.toHaveProperty('bgcolor'); + }); + + it('passes options.backgroundColor to domtoimage as bgcolor', async () => { + const el = document.createElement('div'); + (domtoimage.toPng as Mock).mockResolvedValue('data:image/png;base64,abc'); + + await exportPNG({ + title: 'test', + containerRef: { current: el }, + theme: mockTheme, + options: { backgroundColor: '#ffffff' }, + }); + + expect(domtoimage.toPng).toHaveBeenCalledWith( + el, + expect.objectContaining({ bgcolor: '#ffffff' }), + ); + }); + it('sets the correct download filename', async () => { const el = document.createElement('div'); (domtoimage.toPng as Mock).mockResolvedValue('data:image/png;base64,abc'); diff --git a/src/theme/utils/export.utils.ts b/src/theme/utils/export.utils.ts index 68077cd8..45599c11 100644 --- a/src/theme/utils/export.utils.ts +++ b/src/theme/utils/export.utils.ts @@ -78,7 +78,11 @@ export function exportXLSX({ export async function exportPNG({ title, containerRef, + options, }: ChartCardMenuOptionOnClickProps): Promise { + // Any valid CSS color. When omitted, the PNG background is transparent. + const backgroundColor = options?.backgroundColor as string | undefined; + const element = containerRef?.current; if (!element) { throw new Error('exportPNG: element is undefined'); @@ -87,6 +91,7 @@ export async function exportPNG({ try { const dataUrl = await domtoimage.toPng(element, { cacheBust: true, + ...(backgroundColor && { bgcolor: backgroundColor }), filter: (node: unknown) => { if (node instanceof HTMLElement && node.hasAttribute('data-no-export')) { return false; // exclude elements with data-no-export