Skip to content

Commit 58547d1

Browse files
authored
fix: Fixes chart popover click outside when in iframe (#4005)
1 parent dbfeb04 commit 58547d1

File tree

9 files changed

+198
-51
lines changed

9 files changed

+198
-51
lines changed

pages/app/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ function isAppLayoutPage(pageId?: string) {
5050
'prompt-input/simple',
5151
'funnel-analytics/static-single-page-flow',
5252
'funnel-analytics/static-multi-page-flow',
53-
'charts-with-side-panel',
53+
'charts.test',
5454
];
5555
return pageId !== undefined && appLayoutPages.some(match => pageId.includes(match));
5656
}

pages/app/templates.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Box, SpaceBetween } from '~components';
77
import I18nProvider, { I18nProviderProps } from '~components/i18n';
88
import messages from '~components/i18n/messages/all.en';
99

10+
import { IframeWrapper } from '../utils/iframe-wrapper';
1011
import ScreenshotArea, { ScreenshotAreaProps } from '../utils/screenshot-area';
1112

1213
interface SimplePageProps {
@@ -16,10 +17,11 @@ interface SimplePageProps {
1617
children: React.ReactNode;
1718
screenshotArea?: ScreenshotAreaProps;
1819
i18n?: Partial<I18nProviderProps>;
20+
iframe?: { id?: string };
1921
}
2022

21-
export function SimplePage({ title, subtitle, settings, children, screenshotArea, i18n }: SimplePageProps) {
22-
const content = (
23+
export function SimplePage({ title, subtitle, settings, children, screenshotArea, i18n, iframe }: SimplePageProps) {
24+
let content = (
2325
<Box margin="m">
2426
<SpaceBetween size="m">
2527
<SpaceBetween size="xs">
@@ -44,13 +46,16 @@ export function SimplePage({ title, subtitle, settings, children, screenshotArea
4446
</SpaceBetween>
4547
</Box>
4648
);
47-
return i18n ? (
49+
50+
content = i18n ? (
4851
<I18nProvider messages={[messages]} locale="en-GB" {...i18n}>
4952
{content}
5053
</I18nProvider>
5154
) : (
5255
content
5356
);
57+
58+
return iframe ? <IframeWrapper id={iframe.id ?? 'content-iframe'} AppComponent={() => <>{content}</>} /> : content;
5459
}
5560

5661
export function PermutationsPage({ screenshotArea = {}, ...props }: SimplePageProps) {

pages/charts-with-side-panel.page.tsx renamed to pages/charts.test.page.tsx

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
3-
import React, { useState } from 'react';
3+
import React, { useContext, useState } from 'react';
44

5-
import { AppLayout, Button, MixedLineBarChartProps, PieChart, SpaceBetween, SplitPanel } from '~components';
5+
import {
6+
AppLayout,
7+
Box,
8+
Button,
9+
Checkbox,
10+
Container,
11+
MixedLineBarChartProps,
12+
PieChart,
13+
SpaceBetween,
14+
SplitPanel,
15+
} from '~components';
616
import LineChart from '~components/line-chart';
717

18+
import AppContext, { AppContextType } from './app/app-context';
819
import { SimplePage } from './app/templates';
920
import labels from './app-layout/utils/labels';
1021
import { splitPaneli18nStrings } from './app-layout/utils/strings';
@@ -17,12 +28,22 @@ const linearLatencyProps = createLinearTimeLatencyProps();
1728

1829
type ExpectedSeries = MixedLineBarChartProps.LineDataSeries<number> | MixedLineBarChartProps.ThresholdSeries;
1930

31+
type PageContext = React.Context<
32+
AppContextType<{
33+
iframe?: boolean;
34+
}>
35+
>;
36+
2037
const series: ReadonlyArray<ExpectedSeries> = [
2138
{ title: 'Series 1', type: 'line', data: lineData },
2239
{ title: 'Threshold', type: 'threshold', y: 150 },
2340
];
2441

2542
export default function () {
43+
const {
44+
urlParams: { iframe = false },
45+
setUrlParams,
46+
} = useContext(AppContext as PageContext);
2647
const [splitPanelSize, setSplitPanelSize] = useState(300);
2748
const [sidePanelVisible, setSidePanelVisible] = useState(false);
2849
const toggleButton = <Button onClick={() => setSidePanelVisible(prev => !prev)}>Toggle side panel</Button>;
@@ -45,33 +66,41 @@ export default function () {
4566
title="Line chart with side panel demo"
4667
subtitle="Open side panel from chart's popover. The popover's position should be updated."
4768
screenshotArea={{}}
69+
iframe={iframe ? {} : undefined}
70+
settings={
71+
<SpaceBetween size="s" direction="horizontal">
72+
<Checkbox checked={iframe} onChange={({ detail }) => setUrlParams({ iframe: detail.checked })}>
73+
In iframe
74+
</Checkbox>
75+
</SpaceBetween>
76+
}
4877
>
4978
<SpaceBetween size="m">
50-
<LineChart
51-
{...commonLineProps}
52-
hideFilter={true}
53-
height={200}
54-
series={series}
55-
xTitle="Time"
56-
yTitle="Latency (ms)"
57-
xScaleType="linear"
58-
ariaLabel="Line chart"
59-
detailPopoverFooter={() => toggleButton}
60-
/>
79+
<Container header={<Box variant="h2">Line chart</Box>}>
80+
<LineChart
81+
{...commonLineProps}
82+
hideFilter={true}
83+
height={200}
84+
series={series}
85+
xTitle="Time"
86+
yTitle="Latency (ms)"
87+
xScaleType="linear"
88+
ariaLabel="Line chart"
89+
detailPopoverFooter={() => toggleButton}
90+
/>
91+
</Container>
6192

62-
<AreaChartExample
63-
name="Linear latency chart"
64-
{...linearLatencyProps}
65-
detailPopoverFooter={() => toggleButton}
66-
/>
93+
<AreaChartExample name="Area chart" {...linearLatencyProps} detailPopoverFooter={() => toggleButton} />
6794

68-
<PieChart
69-
{...commonPieProps}
70-
data={pieData}
71-
ariaLabel="Food facts"
72-
size="medium"
73-
detailPopoverFooter={() => toggleButton}
74-
/>
95+
<Container header={<Box variant="h2">Pie chart</Box>}>
96+
<PieChart
97+
{...commonPieProps}
98+
data={pieData}
99+
ariaLabel="Food facts"
100+
size="medium"
101+
detailPopoverFooter={() => toggleButton}
102+
/>
103+
</Container>
75104
</SpaceBetween>
76105
</SimplePage>
77106
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects';
5+
import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';
6+
7+
import createWrapper from '../../../../../lib/components/test-utils/selectors';
8+
9+
describe.each([false, true])('iframe=%s', iframe => {
10+
test(
11+
'can be unpinned by clicking outside',
12+
useBrowser(async browser => {
13+
const page = new BasePageObject(browser);
14+
await browser.url(`#/light/charts.test?iframe=${iframe}`);
15+
await page.runInsideIframe('#content-iframe', iframe, async () => {
16+
const chart = createWrapper().findLineChart();
17+
const popover = chart.findDetailPopover();
18+
const popoverDismiss = popover.findDismissButton();
19+
20+
// Pins popover on the first point.
21+
await page.click('h2');
22+
await page.keys(['Tab', 'ArrowRight', 'Enter']);
23+
await page.waitForVisible(popoverDismiss.toSelector());
24+
25+
// Unpin by clicking outside the chart.
26+
await page.click('h1');
27+
await expect(page.isDisplayed(popover.toSelector())).resolves.toBe(false);
28+
});
29+
})
30+
);
31+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import React from 'react';
5+
import { act, render } from '@testing-library/react';
6+
7+
import ChartPopover from '../../../../../lib/components/internal/components/chart-popover';
8+
9+
describe('click outside', () => {
10+
function TestComponent({ onDismiss }: { onDismiss: () => void }) {
11+
return (
12+
<div>
13+
<div id="outside">x</div>
14+
<div id="container">x</div>
15+
<ChartPopover
16+
trackRef={{ current: null }}
17+
container={document.querySelector('#container')}
18+
onDismiss={onDismiss}
19+
>
20+
<div id="content">x</div>
21+
</ChartPopover>
22+
</div>
23+
);
24+
}
25+
26+
function nextFrame() {
27+
return act(async () => {
28+
await new Promise(resolve => requestAnimationFrame(resolve));
29+
});
30+
}
31+
32+
// We render the component twice to ensure the container reference is set correctly.
33+
function renderPopover({ onDismiss }: { onDismiss: () => void }) {
34+
const { rerender } = render(<TestComponent onDismiss={onDismiss} />);
35+
rerender(<TestComponent onDismiss={onDismiss} />);
36+
}
37+
38+
test('calls popover dismiss on outside click', () => {
39+
const onDismiss = jest.fn();
40+
renderPopover({ onDismiss });
41+
42+
document.querySelector('#outside')!.dispatchEvent(new Event('mousedown', { bubbles: true }));
43+
expect(onDismiss).toHaveBeenCalledWith(true);
44+
});
45+
46+
test('does not call popover dismiss when clicked inside container', () => {
47+
const onDismiss = jest.fn();
48+
renderPopover({ onDismiss });
49+
50+
document.querySelector('#container')!.dispatchEvent(new Event('mousedown', { bubbles: true }));
51+
expect(onDismiss).not.toHaveBeenCalled();
52+
});
53+
54+
test('does not call popover dismiss when clicked inside popover', () => {
55+
const onDismiss = jest.fn();
56+
renderPopover({ onDismiss });
57+
58+
document.querySelector('#content')!.dispatchEvent(new Event('mousedown', { bubbles: true }));
59+
expect(onDismiss).not.toHaveBeenCalled();
60+
});
61+
62+
test('calls popover dismiss when clicked inside popover and then outside', async () => {
63+
const onDismiss = jest.fn();
64+
renderPopover({ onDismiss });
65+
66+
document.querySelector('#content')!.dispatchEvent(new Event('mousedown', { bubbles: true }));
67+
expect(onDismiss).not.toHaveBeenCalled();
68+
69+
await nextFrame();
70+
71+
document.querySelector('#outside')!.dispatchEvent(new Event('mousedown', { bubbles: true }));
72+
expect(onDismiss).toHaveBeenCalledWith(true);
73+
});
74+
});

src/internal/components/chart-popover/index.tsx

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import PopoverBody from '../../../popover/body';
1010
import PopoverContainer from '../../../popover/container';
1111
import { PopoverProps } from '../../../popover/interfaces';
1212
import { getBaseProps } from '../../base-component';
13-
import { nodeBelongs } from '../../utils/node-belongs';
1413

1514
import popoverStyles from '../../../popover/styles.css.js';
1615
import styles from './styles.css.js';
@@ -87,24 +86,31 @@ function ChartPopover(
8786
) {
8887
const baseProps = getBaseProps(restProps);
8988
const popoverObjectRef = useRef<HTMLDivElement | null>(null);
90-
9189
const popoverRef = useMergeRefs(popoverObjectRef, ref);
9290

91+
const clickFrameId = useRef<number | null>(null);
92+
const onMouseDown = () => {
93+
// Indicate there was a click inside popover recently.
94+
clickFrameId.current = requestAnimationFrame(() => (clickFrameId.current = null));
95+
};
96+
9397
useEffect(() => {
94-
const onDocumentClick = (event: MouseEvent) => {
95-
if (
96-
event.target &&
97-
!nodeBelongs(popoverObjectRef.current, event.target as Element) && // click not in popover
98-
!nodeContains(container, event.target as Element) // click not in segment
99-
) {
100-
onDismiss(true);
101-
}
102-
};
103-
104-
document.addEventListener('mousedown', onDocumentClick, { capture: true });
105-
return () => {
106-
document.removeEventListener('mousedown', onDocumentClick, { capture: true });
107-
};
98+
if (popoverObjectRef.current) {
99+
const document = popoverObjectRef.current.ownerDocument;
100+
const onDocumentClick = (event: MouseEvent) => {
101+
// Dismiss popover unless there was a click inside within the last animation frame.
102+
// Ignore clicks inside the chart as those are handled separately.
103+
if (clickFrameId.current === null && !nodeContains(container, event.target as Element)) {
104+
onDismiss(true);
105+
}
106+
};
107+
108+
document.addEventListener('mousedown', onDocumentClick);
109+
110+
return () => {
111+
document.removeEventListener('mousedown', onDocumentClick);
112+
};
113+
}
108114
}, [container, onDismiss]);
109115

110116
// In chart popovers, dismiss button is present when they are pinned, so both values are equivalent.
@@ -117,6 +123,7 @@ function ChartPopover(
117123
ref={popoverRef}
118124
onMouseEnter={onMouseEnter}
119125
onMouseLeave={onMouseLeave}
126+
onMouseDown={onMouseDown}
120127
onBlur={onBlur}
121128
// The tabIndex makes it so that clicking inside popover assigns this element as blur target.
122129
// That is necessary in charts to ensure the blur target is within the chart and no cleanup is needed.

src/internal/utils/dom.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,7 @@ export function isSVGElement(target: unknown): target is SVGElement {
9494
typeof target.ownerSVGElement === 'object')
9595
);
9696
}
97+
98+
export function isElement(target: unknown): target is Element {
99+
return isHTMLElement(target) || isSVGElement(target);
100+
}

src/mixed-line-bar-chart/chart-container.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import VerticalMarker from '../internal/components/cartesian-chart/vertical-mark
1919
import ChartPlot, { ChartPlotRef } from '../internal/components/chart-plot';
2020
import { useHeightMeasure } from '../internal/hooks/container-queries/use-height-measure';
2121
import { useVisualRefresh } from '../internal/hooks/use-visual-mode';
22+
import { isElement } from '../internal/utils/dom';
2223
import { nodeBelongs } from '../internal/utils/node-belongs';
2324
import useContainerWidth from '../internal/utils/use-container-width';
2425
import BarGroups from './bar-groups';
@@ -391,11 +392,7 @@ export default function ChartContainer<T extends ChartDataTypes>({
391392

392393
const onApplicationBlur = (event: React.FocusEvent<Element>) => {
393394
const blurTarget = event.relatedTarget || event.target;
394-
if (
395-
blurTarget === null ||
396-
!(blurTarget instanceof Element) ||
397-
!nodeBelongs(containerRefObject.current, blurTarget)
398-
) {
395+
if (blurTarget === null || !isElement(blurTarget) || !nodeBelongs(containerRefObject.current, blurTarget)) {
399396
clearHighlightedSeries();
400397
setVerticalMarkerX(null);
401398

src/popover/internal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@ function InternalPopover(
6161
const baseProps = getBaseProps(restProps);
6262
const triggerRef = useRef<HTMLElement | null>(null);
6363
const popoverRef = useRef<HTMLDivElement | null>(null);
64-
const clickFrameId = useRef<number | null>(null);
6564

6665
const i18n = useInternalI18n('popover');
6766
const dismissAriaLabel = i18n('dismissAriaLabel', restProps.dismissAriaLabel);
@@ -110,6 +109,7 @@ function InternalPopover(
110109
},
111110
}));
112111

112+
const clickFrameId = useRef<number | null>(null);
113113
useEffect(() => {
114114
if (!triggerRef.current) {
115115
return;

0 commit comments

Comments
 (0)