Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pages/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ function isAppLayoutPage(pageId?: string) {
'prompt-input/simple',
'funnel-analytics/static-single-page-flow',
'funnel-analytics/static-multi-page-flow',
'charts-with-side-panel',
'charts.test',
];
return pageId !== undefined && appLayoutPages.some(match => pageId.includes(match));
}
Expand Down
11 changes: 8 additions & 3 deletions pages/app/templates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Box, SpaceBetween } from '~components';
import I18nProvider, { I18nProviderProps } from '~components/i18n';
import messages from '~components/i18n/messages/all.en';

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

interface SimplePageProps {
Expand All @@ -16,10 +17,11 @@ interface SimplePageProps {
children: React.ReactNode;
screenshotArea?: ScreenshotAreaProps;
i18n?: Partial<I18nProviderProps>;
iframe?: { id?: string };
}

export function SimplePage({ title, subtitle, settings, children, screenshotArea, i18n }: SimplePageProps) {
const content = (
export function SimplePage({ title, subtitle, settings, children, screenshotArea, i18n, iframe }: SimplePageProps) {
let content = (
<Box margin="m">
<SpaceBetween size="m">
<SpaceBetween size="xs">
Expand All @@ -44,13 +46,16 @@ export function SimplePage({ title, subtitle, settings, children, screenshotArea
</SpaceBetween>
</Box>
);
return i18n ? (

content = i18n ? (
<I18nProvider messages={[messages]} locale="en-GB" {...i18n}>
{content}
</I18nProvider>
) : (
content
);

return iframe ? <IframeWrapper id={iframe.id ?? 'content-iframe'} AppComponent={() => <>{content}</>} /> : content;
}

export function PermutationsPage({ screenshotArea = {}, ...props }: SimplePageProps) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useState } from 'react';
import React, { useContext, useState } from 'react';

import { AppLayout, Button, MixedLineBarChartProps, PieChart, SpaceBetween, SplitPanel } from '~components';
import {
AppLayout,
Box,
Button,
Checkbox,
Container,
MixedLineBarChartProps,
PieChart,
SpaceBetween,
SplitPanel,
} from '~components';
import LineChart from '~components/line-chart';

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

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

type PageContext = React.Context<
AppContextType<{
iframe?: boolean;
}>
>;

const series: ReadonlyArray<ExpectedSeries> = [
{ title: 'Series 1', type: 'line', data: lineData },
{ title: 'Threshold', type: 'threshold', y: 150 },
];

export default function () {
const {
urlParams: { iframe = false },
setUrlParams,
} = useContext(AppContext as PageContext);
const [splitPanelSize, setSplitPanelSize] = useState(300);
const [sidePanelVisible, setSidePanelVisible] = useState(false);
const toggleButton = <Button onClick={() => setSidePanelVisible(prev => !prev)}>Toggle side panel</Button>;
Expand All @@ -45,33 +66,41 @@ export default function () {
title="Line chart with side panel demo"
subtitle="Open side panel from chart's popover. The popover's position should be updated."
screenshotArea={{}}
iframe={iframe ? {} : undefined}
settings={
<SpaceBetween size="s" direction="horizontal">
<Checkbox checked={iframe} onChange={({ detail }) => setUrlParams({ iframe: detail.checked })}>
In iframe
</Checkbox>
</SpaceBetween>
}
>
<SpaceBetween size="m">
<LineChart
{...commonLineProps}
hideFilter={true}
height={200}
series={series}
xTitle="Time"
yTitle="Latency (ms)"
xScaleType="linear"
ariaLabel="Line chart"
detailPopoverFooter={() => toggleButton}
/>
<Container header={<Box variant="h2">Line chart</Box>}>
<LineChart
{...commonLineProps}
hideFilter={true}
height={200}
series={series}
xTitle="Time"
yTitle="Latency (ms)"
xScaleType="linear"
ariaLabel="Line chart"
detailPopoverFooter={() => toggleButton}
/>
</Container>

<AreaChartExample
name="Linear latency chart"
{...linearLatencyProps}
detailPopoverFooter={() => toggleButton}
/>
<AreaChartExample name="Area chart" {...linearLatencyProps} detailPopoverFooter={() => toggleButton} />

<PieChart
{...commonPieProps}
data={pieData}
ariaLabel="Food facts"
size="medium"
detailPopoverFooter={() => toggleButton}
/>
<Container header={<Box variant="h2">Pie chart</Box>}>
<PieChart
{...commonPieProps}
data={pieData}
ariaLabel="Food facts"
size="medium"
detailPopoverFooter={() => toggleButton}
/>
</Container>
</SpaceBetween>
</SimplePage>
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects';
import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';

import createWrapper from '../../../../../lib/components/test-utils/selectors';

describe.each([false, true])('iframe=%s', iframe => {
test(
'can be unpinned by clicking outside',
useBrowser(async browser => {
const page = new BasePageObject(browser);
await browser.url(`#/light/charts.test?iframe=${iframe}`);
await page.runInsideIframe('#content-iframe', iframe, async () => {
const chart = createWrapper().findLineChart();
const popover = chart.findDetailPopover();
const popoverDismiss = popover.findDismissButton();

// Pins popover on the first point.
await page.click('h2');
await page.keys(['Tab', 'ArrowRight', 'Enter']);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gethinwebster I changed popover activation from pointer to keyboard because the former approach did not work with React 16 test pages: the page.hoverElement() did not help with offsetting the click. I did test the feature manually on both React versions.

await page.waitForVisible(popoverDismiss.toSelector());

// Unpin by clicking outside the chart.
await page.click('h1');
await expect(page.isDisplayed(popover.toSelector())).resolves.toBe(false);
});
})
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import React from 'react';
import { act, render } from '@testing-library/react';

import ChartPopover from '../../../../../lib/components/internal/components/chart-popover';

describe('click outside', () => {
function TestComponent({ onDismiss }: { onDismiss: () => void }) {
return (
<div>
<div id="outside">x</div>
<div id="container">x</div>
<ChartPopover
trackRef={{ current: null }}
container={document.querySelector('#container')}
onDismiss={onDismiss}
>
<div id="content">x</div>
</ChartPopover>
</div>
);
}

function nextFrame() {
return act(async () => {
await new Promise(resolve => requestAnimationFrame(resolve));
});
}

// We render the component twice to ensure the container reference is set correctly.
function renderPopover({ onDismiss }: { onDismiss: () => void }) {
const { rerender } = render(<TestComponent onDismiss={onDismiss} />);
rerender(<TestComponent onDismiss={onDismiss} />);
}

test('calls popover dismiss on outside click', () => {
const onDismiss = jest.fn();
renderPopover({ onDismiss });

document.querySelector('#outside')!.dispatchEvent(new Event('mousedown', { bubbles: true }));
expect(onDismiss).toHaveBeenCalledWith(true);
});

test('does not call popover dismiss when clicked inside container', () => {
const onDismiss = jest.fn();
renderPopover({ onDismiss });

document.querySelector('#container')!.dispatchEvent(new Event('mousedown', { bubbles: true }));
expect(onDismiss).not.toHaveBeenCalled();
});

test('does not call popover dismiss when clicked inside popover', () => {
const onDismiss = jest.fn();
renderPopover({ onDismiss });

document.querySelector('#content')!.dispatchEvent(new Event('mousedown', { bubbles: true }));
expect(onDismiss).not.toHaveBeenCalled();
});

test('calls popover dismiss when clicked inside popover and then outside', async () => {
const onDismiss = jest.fn();
renderPopover({ onDismiss });

document.querySelector('#content')!.dispatchEvent(new Event('mousedown', { bubbles: true }));
expect(onDismiss).not.toHaveBeenCalled();

await nextFrame();

document.querySelector('#outside')!.dispatchEvent(new Event('mousedown', { bubbles: true }));
expect(onDismiss).toHaveBeenCalledWith(true);
});
});
39 changes: 23 additions & 16 deletions src/internal/components/chart-popover/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import PopoverBody from '../../../popover/body';
import PopoverContainer from '../../../popover/container';
import { PopoverProps } from '../../../popover/interfaces';
import { getBaseProps } from '../../base-component';
import { nodeBelongs } from '../../utils/node-belongs';

import popoverStyles from '../../../popover/styles.css.js';
import styles from './styles.css.js';
Expand Down Expand Up @@ -87,24 +86,31 @@ function ChartPopover(
) {
const baseProps = getBaseProps(restProps);
const popoverObjectRef = useRef<HTMLDivElement | null>(null);

const popoverRef = useMergeRefs(popoverObjectRef, ref);

const clickFrameId = useRef<number | null>(null);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use the same implementation in the internal popover component. The chart popover cannot use it because it imports the popover container instead.

const onMouseDown = () => {
// Indicate there was a click inside popover recently.
clickFrameId.current = requestAnimationFrame(() => (clickFrameId.current = null));
};

useEffect(() => {
const onDocumentClick = (event: MouseEvent) => {
if (
event.target &&
!nodeBelongs(popoverObjectRef.current, event.target as Element) && // click not in popover
!nodeContains(container, event.target as Element) // click not in segment
) {
onDismiss(true);
}
};

document.addEventListener('mousedown', onDocumentClick, { capture: true });
return () => {
document.removeEventListener('mousedown', onDocumentClick, { capture: true });
};
if (popoverObjectRef.current) {
const document = popoverObjectRef.current.ownerDocument;
const onDocumentClick = (event: MouseEvent) => {
// Dismiss popover unless there was a click inside within the last animation frame.
// Ignore clicks inside the chart as those are handled separately.
if (clickFrameId.current === null && !nodeContains(container, event.target as Element)) {
onDismiss(true);
}
};

document.addEventListener('mousedown', onDocumentClick);

return () => {
document.removeEventListener('mousedown', onDocumentClick);
};
}
}, [container, onDismiss]);

// In chart popovers, dismiss button is present when they are pinned, so both values are equivalent.
Expand All @@ -117,6 +123,7 @@ function ChartPopover(
ref={popoverRef}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onMouseDown={onMouseDown}
onBlur={onBlur}
// The tabIndex makes it so that clicking inside popover assigns this element as blur target.
// That is necessary in charts to ensure the blur target is within the chart and no cleanup is needed.
Expand Down
4 changes: 4 additions & 0 deletions src/internal/utils/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,7 @@ export function isSVGElement(target: unknown): target is SVGElement {
typeof target.ownerSVGElement === 'object')
);
}

export function isElement(target: unknown): target is Element {
return isHTMLElement(target) || isSVGElement(target);
}
7 changes: 2 additions & 5 deletions src/mixed-line-bar-chart/chart-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import VerticalMarker from '../internal/components/cartesian-chart/vertical-mark
import ChartPlot, { ChartPlotRef } from '../internal/components/chart-plot';
import { useHeightMeasure } from '../internal/hooks/container-queries/use-height-measure';
import { useVisualRefresh } from '../internal/hooks/use-visual-mode';
import { isElement } from '../internal/utils/dom';
import { nodeBelongs } from '../internal/utils/node-belongs';
import useContainerWidth from '../internal/utils/use-container-width';
import BarGroups from './bar-groups';
Expand Down Expand Up @@ -391,11 +392,7 @@ export default function ChartContainer<T extends ChartDataTypes>({

const onApplicationBlur = (event: React.FocusEvent<Element>) => {
const blurTarget = event.relatedTarget || event.target;
if (
blurTarget === null ||
!(blurTarget instanceof Element) ||
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The blurTarget instanceof Element fails if the target belongs to a different document. As result, this if condition always resolves to true and thus causes the popover to be dismissed unexpectedly.

See:

Screen.Recording.2025-11-04.at.10.18.49.mov

Replacing the check with !isElement(blurTarget) fixes the problem.

!nodeBelongs(containerRefObject.current, blurTarget)
) {
if (blurTarget === null || !isElement(blurTarget) || !nodeBelongs(containerRefObject.current, blurTarget)) {
clearHighlightedSeries();
setVerticalMarkerX(null);

Expand Down
2 changes: 1 addition & 1 deletion src/popover/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ function InternalPopover(
const baseProps = getBaseProps(restProps);
const triggerRef = useRef<HTMLElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const clickFrameId = useRef<number | null>(null);

const i18n = useInternalI18n('popover');
const dismissAriaLabel = i18n('dismissAriaLabel', restProps.dismissAriaLabel);
Expand Down Expand Up @@ -110,6 +109,7 @@ function InternalPopover(
},
}));

const clickFrameId = useRef<number | null>(null);
useEffect(() => {
if (!triggerRef.current) {
return;
Expand Down
Loading