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
6 changes: 6 additions & 0 deletions packages/docs/docs/api/components/Axis.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ The `Axis` component is used to display the axis, axis labels and grid marks. An
<td>false</td>
<td>Displays gridlines at each tick location.</td>
</tr>
<tr>
<td>hasPopover</td>
<td>boolean</td>
<td>false</td>
<td>When <code>true</code>, enables linking axis thumbnails to the chart’s bar popover. Clicking an <code>AxisThumbnail</code> on this axis opens the same <code>ChartPopover</code> that is defined on a <code>Bar</code> mark, anchored to the matching bar. This flow applies only to <code>Bar</code> charts, and requires <code>Bar</code> to have a <code>ChartPopover</code> child.</td>
</tr>
<tr>
<td>hideDefaultLabels</td>
<td>boolean</td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const Axis: FC<AxisProps> = ({
tickCountMinimum = undefined,
tickMinStep = undefined,
title = undefined,
hasPopover = false,
}) => {
return null;
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright 2026 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import { createElement, useMemo } from 'react';

import { DEFAULT_CATEGORICAL_DIMENSION } from '@spectrum-charts/constants';

import { Axis, Bar, ChartPopover } from '../components';
import { AxisThumbnail } from '../components/AxisThumbnail';
import { AxisElement, BarElement, ChartChildElement } from '../types';
import {
getAllMarkElements,
getComponentName,
getElementDisplayName,
sanitizeAxisChildren,
sanitizeMarkChildren,
} from '../utils';

export interface ThumbnailPopoverConfig {
/** The names of thumbnail marks that should trigger the popover, e.g. ['axis0AxisThumbnail0'] */
thumbnailNames: string[];
/** The dimension field used to find the matching bar item, e.g. 'category' */
dimensionField: string;
/** The bar mark name used to trigger the popover, e.g. 'bar0' */
barMarkName: string;
}

const ChartContainer = ({ children }: { children: React.ReactNode }) => createElement('div', null, children);
ChartContainer.displayName = 'ChartContainer';

/**
* First Bar in document order that declares a ChartPopover among its mark children.
* Used to anchor axis-thumbnail clicks to the bar series that owns the popover UI.
*/
function findFirstBarWithChartPopover(children: ChartChildElement[]) {
const barElements = getAllMarkElements(createElement(ChartContainer, undefined, children), Bar);
return barElements.find(({ element }) => {
const markChildren = sanitizeMarkChildren((element as BarElement).props.children);
return markChildren.some((child) => getElementDisplayName(child) === ChartPopover.displayName);
});
}

/**
* Finds the first bar mark with a ChartPopover child and collects all AxisThumbnail
* names on axes with hasPopover=true, returning a config that links them together.
*/
export default function useAxisThumbnailPopover(children: ChartChildElement[]): ThumbnailPopoverConfig | undefined {
return useMemo(() => {
const barWithPopover = findFirstBarWithChartPopover(children);

if (!barWithPopover) return undefined;

const barMarkName = barWithPopover.name;
const barElement = barWithPopover.element as BarElement;
const dimensionField = barElement.props.dimension ?? DEFAULT_CATEGORICAL_DIMENSION;

const thumbnailNames: string[] = [];
let axisCount = -1;

for (const child of children) {
if (getElementDisplayName(child) !== Axis.displayName) continue;
axisCount++;
const axisElement = child as AxisElement;
if (!axisElement.props.hasPopover) continue;
const axisName = getComponentName(axisElement, `axis${axisCount}`);

const axisChildren = sanitizeAxisChildren(axisElement.props.children);
let thumbnailIndex = 0;
for (const axisChild of axisChildren) {
if (getElementDisplayName(axisChild) === AxisThumbnail.displayName) {
thumbnailNames.push(`${axisName}AxisThumbnail${thumbnailIndex}`);
thumbnailIndex++;
}
}
}

if (thumbnailNames.length === 0) return undefined;

return { thumbnailNames, dimensionField, barMarkName };
}, [children]);
}
7 changes: 6 additions & 1 deletion packages/react-spectrum-charts/src/hooks/useNewChartView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
getOnMouseInputCallback,
setSelectedSignals,
} from '../utils';
import useAxisThumbnailPopover from './useAxisThumbnailPopover';
import useLegend from './useLegend';
import useMarkMouseInputDetails from './useMarkMouseInputDetails';
import useMarkOnClickDetails from './useMarkOnClickDetails';
Expand All @@ -48,6 +49,7 @@ const useNewChartView = (
} = useLegend(sanitizedChildren); // gets props from the legend if it exists
const markClickDetails = useMarkOnClickDetails(sanitizedChildren);
const markMouseInputDetails = useMarkMouseInputDetails(sanitizedChildren);
const thumbnailPopoverConfig = useAxisThumbnailPopover(sanitizedChildren);

const legendHasPopover = useMemo(
() => popovers.some((p) => p.parent === Legend.displayName && !p.chartPopoverProps.rightClick),
Expand Down Expand Up @@ -107,7 +109,8 @@ const useNewChartView = (
legendHasPopover,
onLegendClick,
trigger: 'click',
markHasPopover: markHasPopover
markHasPopover: markHasPopover,
thumbnailPopoverConfig,
})
);
if (popovers.some((p) => p.chartPopoverProps.rightClick)) {
Expand All @@ -130,6 +133,7 @@ const useNewChartView = (
onLegendClick,
trigger: 'contextmenu',
markHasPopover: markHasPopover,
thumbnailPopoverConfig,
})
);
}
Expand Down Expand Up @@ -164,6 +168,7 @@ const useNewChartView = (
selectedDataBounds,
selectedDataName,
setLegendHiddenSeries,
thumbnailPopoverConfig,
tooltipOptions,
]
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,25 +128,64 @@ const ChartPopoverSvgStory: StoryFn<typeof ChartPopover> = (args): ReactElement

const Popover = bindWithProps(ChartPopoverSvgStory);
Popover.args = { children: dialogContent, width: 'auto' };
Popover.storyName = 'Popover';

const singleBarDialogContent = (datum: Datum) => (
<Content>
<div>Browser: {datum.browser as string}</div>
<div>Downloads: {(datum.downloads as number)?.toLocaleString()}</div>
</Content>
);

const WithThumbnailPopoverStory: StoryFn<typeof AxisThumbnail> = (): ReactElement => {
const chartProps = useChartProps({ data, renderer: 'svg', width: 600 });
return (
<Chart {...chartProps}>
<Bar dimension="browser" metric="downloads">
<ChartPopover width="auto">{singleBarDialogContent}</ChartPopover>
</Bar>
<Axis position="bottom" baseline hasPopover>
<AxisThumbnail urlKey="thumbnail" />
</Axis>
</Chart>
);
};

const WithThumbnailPopover = bindWithProps(WithThumbnailPopoverStory);
WithThumbnailPopover.args = {};

const DodgedBarWithThumbnailPopoverStory: StoryFn<typeof AxisThumbnail> = (): ReactElement => {
const chartProps = useChartProps({ data: chartPopoverDataWithThumbnails, renderer: 'svg', width: 600 });
return (
<Chart {...chartProps}>
<Bar color="series" type="dodged">
<ChartPopover width="auto">{dialogContent}</ChartPopover>
</Bar>
<Axis position="bottom" baseline hasPopover>
<AxisThumbnail urlKey="thumbnail" />
</Axis>
</Chart>
);
};

const simpleDialogContent = (datum: Datum) => {
const d = datum[GROUP_DATA]?.[0] ?? datum;
return(
<Content>
<div>Browser: {d.browser as string}</div>
<div>Downloads: {(d.downloads as number)?.toLocaleString()}</div>
</Content>
)
};

const DodgedBarWithThumbnailPopover = bindWithProps(DodgedBarWithThumbnailPopoverStory);
DodgedBarWithThumbnailPopover.args = {};

const DodgedBarWithTooltipsStory: StoryFn<typeof ChartTooltip> = (): ReactElement => {
const chartProps = useChartProps({ data: chartPopoverDataWithThumbnails, renderer: 'svg', width: 600 });
return (
<Chart {...chartProps}>
<Bar color="series" type="dodged">
<ChartTooltip>
{(datum) => {
console.log('DODGED TOOLTIP datum:', datum);
return (
<div>
<div>Operating system: {datum.series}</div>
<div>Browser: {datum.category}</div>
<div>Users: {datum.value?.toLocaleString()}</div>
</div>
);
}}
</ChartTooltip>
<ChartTooltip>{dialogContent}</ChartTooltip>
</Bar>
<Axis position="bottom" baseline>
<AxisThumbnail urlKey="thumbnail" />
Expand All @@ -169,7 +208,6 @@ const DodgedBarWithTooltipsStory: StoryFn<typeof ChartTooltip> = (): ReactElemen

const DodgedBarWithTooltips = bindWithProps(DodgedBarWithTooltipsStory);
DodgedBarWithTooltips.args = {};
DodgedBarWithTooltips.storyName = 'Dodged Bar with Tooltips';

const AxisThumbnailTooltipStory: StoryFn<StoryArgs> = (args): ReactElement => {
const { orientation, width, ...axisThumbnailProps } = args;
Expand All @@ -194,33 +232,11 @@ const AxisThumbnailTooltipStory: StoryFn<StoryArgs> = (args): ReactElement => {
>
<Chart {...chartProps}>
<Bar orientation={orientation} dimension="browser" metric="downloads">
<ChartTooltip targets={['item', 'dimensionArea']}>
{(datum) => {
const d = datum[GROUP_DATA]?.[0] ?? datum;
console.log('TOOLTIP datum:', d);
return (
<div>
<div>Browser: {d.browser}</div>
<div>Downloads: {d.downloads?.toLocaleString()}</div>
</div>
);
}}
</ChartTooltip>
<ChartTooltip targets={['item', 'dimensionArea']}>{simpleDialogContent}</ChartTooltip>
</Bar>
<Axis position={orientation === 'horizontal' ? 'left' : 'bottom'} baseline>
<AxisThumbnail {...axisThumbnailProps} />
<ChartTooltip>
{(datum) => {
const d = datum[GROUP_DATA]?.[0] ?? datum;

return (
<div>
<div>Browser: {d.browser}</div>
<div>Downloads: {d.downloads?.toLocaleString()}</div>
</div>
);
}}
</ChartTooltip>
<ChartTooltip>{simpleDialogContent}</ChartTooltip>
</Axis>
</Chart>
</View>
Expand All @@ -232,6 +248,13 @@ YAxisWithTooltip.args = {
urlKey: 'thumbnail',
orientation: 'horizontal',
};
YAxisWithTooltip.storyName = 'YAxis with Tooltip';

export { Basic, YAxis, Popover, DodgedBarWithTooltips, YAxisWithTooltip };
export {
Basic,
YAxis,
Popover,
WithThumbnailPopover,
DodgedBarWithThumbnailPopover,
DodgedBarWithTooltips,
YAxisWithTooltip,
};
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import userEvent from '@testing-library/user-event';
import { AxisThumbnail } from '../../../components';
import {
allElementsHaveAttributeValue,
clickNthElement,
findAllMarksByGroupName,
findChart,
getPopoverTriggerButtons,
Expand All @@ -28,7 +29,15 @@ import {
import '../../../test-utils/__mocks__/matchMedia.mock.js';
// Mock Image so that Vega's image loading completes immediately in jsdom.
import '../../../test-utils/__mocks__/image.mock.js';
import { Basic, Popover, YAxis, YAxisWithTooltip, DodgedBarWithTooltips } from './AxisThumbnail.story';
import {
Basic,
DodgedBarWithThumbnailPopover,
DodgedBarWithTooltips,
Popover,
WithThumbnailPopover,
YAxis,
YAxisWithTooltip,
} from './AxisThumbnail.story';
import { Resizable } from './AxisThumbnailResize.story';

describe('AxisThumbnail', () => {
Expand Down Expand Up @@ -215,4 +224,62 @@ describe('AxisThumbnail', () => {
expect(tooltipContent.getByText(/Browser:/)).toBeInTheDocument();
expect(tooltipContent.getByText(/Users:/)).toBeInTheDocument();
});

test('With Thumbnail Popover: clicking thumbnail opens popover with correct content', async () => {
render(<WithThumbnailPopover {...WithThumbnailPopover.args} />);
const chart = await findChart();
expect(chart).toBeInTheDocument();

const thumbnailMarks = await findAllMarksByGroupName(chart, 'axis0AxisThumbnail0', 'image');
expect(thumbnailMarks.length).toBeGreaterThan(0);

await clickNthElement(thumbnailMarks, 0);

const popover = await screen.findByTestId('rsc-popover');
await waitFor(() => expect(popover).toBeInTheDocument());
expect(within(popover).getByText(/Browser:/)).toBeInTheDocument();
expect(within(popover).getByText(/Downloads:/)).toBeInTheDocument();
});

test('With Thumbnail Popover: clicking thumbnail highlights the corresponding bar', async () => {
render(<WithThumbnailPopover {...WithThumbnailPopover.args} />);
const chart = await findChart();
expect(chart).toBeInTheDocument();

const thumbnailMarks = await findAllMarksByGroupName(chart, 'axis0AxisThumbnail0', 'image');
await clickNthElement(thumbnailMarks, 0);

const bars = await findAllMarksByGroupName(chart, 'bar0');
expect(bars[0]).toHaveAttribute('opacity', '1');
expect(bars[1]).toHaveAttribute('opacity', `${FADE_FACTOR}`);
});

test('Dodged Bar with Thumbnail Popover: clicking thumbnail opens popover', async () => {
render(<DodgedBarWithThumbnailPopover {...DodgedBarWithThumbnailPopover.args} />);
const chart = await findChart();
expect(chart).toBeInTheDocument();

const thumbnailMarks = await findAllMarksByGroupName(chart, 'axis0AxisThumbnail0', 'image');
expect(thumbnailMarks.length).toBeGreaterThan(0);

await clickNthElement(thumbnailMarks, 0);

const popover = await screen.findByTestId('rsc-popover');
await waitFor(() => expect(popover).toBeInTheDocument());
expect(within(popover).getByText(/Browser:/)).toBeInTheDocument();
});

test('Dodged Bar with Thumbnail Popover: clicking thumbnail highlights the corresponding bar', async () => {
render(<DodgedBarWithThumbnailPopover {...DodgedBarWithThumbnailPopover.args} />);
const chart = await findChart();
expect(chart).toBeInTheDocument();

const thumbnailMarks = await findAllMarksByGroupName(chart, 'axis0AxisThumbnail0', 'image');
await clickNthElement(thumbnailMarks, 0);

const bars = await findAllMarksByGroupName(chart, 'bar0');
expect(bars[0]).toHaveAttribute('opacity', '1');
expect(bars[1]).toHaveAttribute('opacity', `${FADE_FACTOR}`);
});

});
Loading
Loading