Skip to content

Commit 4b48e05

Browse files
Merge pull request #602 from adobe/clamoureux/controledMouseInputsBar
feat: added onMouseOver and onMouseOut props to Bar
2 parents 342b23c + c4a9435 commit 4b48e05

File tree

10 files changed

+244
-18
lines changed

10 files changed

+244
-18
lines changed

packages/docs/docs/api/visualizations/Bar.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,18 @@ If you only have one series in your data, both the `type` and `color` props can
202202
<td>–</td>
203203
<td>Callback that will be run when a point/section is clicked.</td>
204204
</tr>
205+
<tr>
206+
<td>onMouseOver</td>
207+
<td>function</td>
208+
<td>–</td>
209+
<td>Callback that will be run when a bar is hovered.</td>
210+
</tr>
211+
<tr>
212+
<td>onMouseOut</td>
213+
<td>function</td>
214+
<td>–</td>
215+
<td>Callback that will be run when a bar is no longer hovered.</td>
216+
</tr>
205217
<tr>
206218
<td>metric</td>
207219
<td>string</td>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
import { createElement, useMemo } from 'react';
13+
14+
import { Datum } from '@spectrum-charts/vega-spec-builder';
15+
16+
import { Bar, BarElement, Chart, ChartChildElement } from '../index';
17+
import { getAllMarkElements } from '../utils';
18+
19+
type MappedMarkElement = { name: string; element: BarElement };
20+
21+
export type MarkMouseInputDetail = {
22+
markName?: string;
23+
onMouseOver?: (datum: Datum) => void;
24+
onMouseOut?: (datum: Datum) => void;
25+
};
26+
27+
export default function useMarkMouseInputDetails(children: ChartChildElement[]): MarkMouseInputDetail[] {
28+
const markElements = useMemo(() => {
29+
return [
30+
...getAllMarkElements(createElement(Chart, { data: [] }, children), Bar, []),
31+
] as MappedMarkElement[];
32+
}, [children]);
33+
34+
return useMemo(
35+
() =>
36+
markElements
37+
.filter((mark) => {
38+
const barProps = mark.element.props;
39+
return barProps.onMouseOver || barProps.onMouseOut;
40+
})
41+
.map((mark) => {
42+
const barProps = mark.element.props;
43+
return {
44+
markName: mark.name,
45+
onMouseOver: barProps.onMouseOver,
46+
onMouseOut: barProps.onMouseOut,
47+
};
48+
}) as MarkMouseInputDetail[],
49+
[markElements]
50+
);
51+
}

packages/react-spectrum-charts/src/hooks/useMarkOnClickDetails.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@
1111
*/
1212
import { createElement, useMemo } from 'react';
1313

14-
import { Bar, BarElement, Chart, ChartChildElement, Line, LineElement, OnClickCallback } from '../index';
14+
import { Bar, BarElement, Chart, ChartChildElement, Line, LineElement, MarkCallback } from '../index';
1515
import { getAllMarkElements } from '../utils';
1616

1717
type MappedMarkElement = { name: string; element: BarElement | LineElement };
1818

1919
export type MarkOnClickDetail = {
2020
markName?: string;
21-
onClick?: OnClickCallback;
21+
onClick?: MarkCallback;
2222
};
2323

2424
export default function useMarkOnClickDetails(children: ChartChildElement[]): MarkOnClickDetail[] {

packages/react-spectrum-charts/src/hooks/useNewChartView.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
setSelectedSignals,
2727
} from '../utils';
2828
import useLegend from './useLegend';
29+
import useMarkMouseInputDetails from './useMarkMouseInputDetails';
2930
import useMarkOnClickDetails from './useMarkOnClickDetails';
3031
import usePopovers from './usePopovers';
3132

@@ -45,6 +46,7 @@ const useNewChartView = (
4546
onMouseOver: onLegendMouseOver,
4647
} = useLegend(sanitizedChildren); // gets props from the legend if it exists
4748
const markClickDetails = useMarkOnClickDetails(sanitizedChildren);
49+
const markMouseInputDetails = useMarkMouseInputDetails(sanitizedChildren);
4850

4951
const legendHasPopover = useMemo(
5052
() => popovers.some((p) => p.parent === Legend.displayName && !p.chartPopoverProps.rightClick),
@@ -125,8 +127,8 @@ const useNewChartView = (
125127
}
126128
}
127129
view.addEventListener('click', getOnChartMarkClickCallback(chartView, markClickDetails));
128-
view.addEventListener('mouseover', getOnMouseInputCallback(onLegendMouseOver));
129-
view.addEventListener('mouseout', getOnMouseInputCallback(onLegendMouseOut));
130+
view.addEventListener('mouseover', getOnMouseInputCallback(onLegendMouseOver, markMouseInputDetails));
131+
view.addEventListener('mouseout', getOnMouseInputCallback(onLegendMouseOut, markMouseInputDetails));
130132
},
131133
[
132134
chartId,
@@ -137,6 +139,7 @@ const useNewChartView = (
137139
legendHiddenSeries,
138140
legendIsToggleable,
139141
markClickDetails,
142+
markMouseInputDetails,
140143
onLegendClick,
141144
onLegendMouseOut,
142145
onLegendMouseOver,

packages/react-spectrum-charts/src/stories/components/Bar/Bar.story.tsx

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@
99
* OF ANY KIND, either express or implied. See the License for the specific language
1010
* governing permissions and limitations under the License.
1111
*/
12-
import React, { ReactElement, createElement } from 'react';
12+
import React, { ReactElement, createElement, useState } from 'react';
1313

1414
import { StoryFn } from '@storybook/react';
1515

16+
import { Datum } from '@spectrum-charts/vega-spec-builder';
17+
1618
import { Annotation } from '../../../components/Annotation';
1719
import useChartProps from '../../../hooks/useChartProps';
1820
import { Axis, Bar, BarProps, Chart } from '../../../index';
@@ -41,6 +43,41 @@ const BarStoryWithUTCData: StoryFn<typeof Bar> = (args): ReactElement => {
4143
);
4244
};
4345

46+
const OnMouseInputsStory: StoryFn<typeof Bar> = (args): ReactElement => {
47+
const [hoveredData, setHoveredData] = useState<Datum | null>(null);
48+
const [isHovering, setIsHovering] = useState(false);
49+
50+
const controlledMouseOver = (datum: Datum) => {
51+
if (!isHovering) {
52+
setHoveredData(datum);
53+
setIsHovering(true);
54+
}
55+
};
56+
const controlledMouseOut = () => {
57+
if (isHovering) {
58+
setIsHovering(false);
59+
}
60+
};
61+
62+
const chartProps = useChartProps({ data: barData, width: 600, height: 600 });
63+
return (
64+
<div>
65+
<div data-testid="hover-info">
66+
{isHovering && hoveredData ? (
67+
<div data-testid="hover-data">{JSON.stringify(hoveredData, null, 2)}</div>
68+
) : (
69+
<div data-testid="no-hover">No bar hovered</div>
70+
)}
71+
</div>
72+
<Chart {...chartProps}>
73+
<Axis position={args.orientation === 'horizontal' ? 'left' : 'bottom'} baseline title="Browser" />
74+
<Axis position={args.orientation === 'horizontal' ? 'bottom' : 'left'} grid title="Downloads" />
75+
<Bar {...args} onMouseOver={controlledMouseOver} onMouseOut={controlledMouseOut} />
76+
</Chart>
77+
</div>
78+
);
79+
};
80+
4481
const BarStory: StoryFn<typeof Bar> = (args): ReactElement => {
4582
const chartProps = useChartProps({ data: barData, width: 600, height: 600 });
4683
return (
@@ -107,6 +144,12 @@ OnClick.args = {
107144
metric: 'downloads',
108145
};
109146

147+
const OnMouseInputs = bindWithProps(OnMouseInputsStory);
148+
OnMouseInputs.args = {
149+
dimension: 'browser',
150+
metric: 'downloads',
151+
};
152+
110153
const BarWithUTCDatetimeFormat = bindWithProps(BarStoryWithUTCData);
111154
BarWithUTCDatetimeFormat.args = {
112155
...defaultProps,
@@ -125,5 +168,6 @@ export {
125168
WithAnnotation,
126169
HasSquareCorners,
127170
OnClick,
171+
OnMouseInputs,
128172
BarWithUTCDatetimeFormat,
129173
};

packages/react-spectrum-charts/src/stories/components/Bar/Bar.test.tsx

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,25 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212
import { Bar } from '../../../components';
13-
import { clickNthElement, findAllMarksByGroupName, findChart, render } from '../../../test-utils';
13+
import {
14+
clickNthElement,
15+
findAllMarksByGroupName,
16+
findChart,
17+
hoverNthElement,
18+
render,
19+
screen,
20+
unhoverNthElement,
21+
} from '../../../test-utils';
1422
import '../../../test-utils/__mocks__/matchMedia.mock.js';
15-
import { BarWithUTCDatetimeFormat, Basic, OnClick, Opacity, PaddingRatio, WithAnnotation } from './Bar.story';
23+
import {
24+
BarWithUTCDatetimeFormat,
25+
Basic,
26+
OnClick,
27+
OnMouseInputs,
28+
Opacity,
29+
PaddingRatio,
30+
WithAnnotation,
31+
} from './Bar.story';
1632
import { Color, DodgedStacked } from './DodgedBar.story';
1733
import { Basic as StackedBasic } from './StackedBar.story';
1834
import { barData } from './data';
@@ -128,4 +144,70 @@ describe('Bar', () => {
128144
await clickNthElement(bars, 4);
129145
expect(onClick).toHaveBeenCalledWith(expect.objectContaining(barData[4]));
130146
});
147+
148+
test('should call onMouseOver and onMouseOut callbacks when hovering bar items', async () => {
149+
const onMouseOver = jest.fn();
150+
const onMouseOut = jest.fn();
151+
render(<Basic {...Basic.args} onMouseOver={onMouseOver} onMouseOut={onMouseOut} />);
152+
const chart = await findChart();
153+
const bars = await findAllMarksByGroupName(chart, 'bar0');
154+
155+
await hoverNthElement(bars, 0);
156+
expect(onMouseOver).toHaveBeenCalledWith(expect.objectContaining(barData[0]));
157+
158+
await unhoverNthElement(bars, 0);
159+
expect(onMouseOut).toHaveBeenCalledWith(expect.objectContaining(barData[0]));
160+
});
161+
162+
test('should display custom hover information in UI when mousing over bar items', async () => {
163+
render(<OnMouseInputs {...OnMouseInputs.args} />);
164+
const chart = await findChart();
165+
const bars = await findAllMarksByGroupName(chart, 'bar0');
166+
167+
// Initially no hover info should be displayed
168+
expect(screen.getByTestId('no-hover')).toBeInTheDocument();
169+
expect(screen.queryByTestId('hover-data')).not.toBeInTheDocument();
170+
171+
// Hover over first bar (Chrome, 27000)
172+
await hoverNthElement(bars, 0);
173+
174+
expect(screen.queryByTestId('no-hover')).not.toBeInTheDocument();
175+
let hoverData = screen.getByTestId('hover-data');
176+
expect(hoverData).toBeInTheDocument();
177+
178+
const firstBarData = JSON.parse(hoverData.textContent || '{}');
179+
expect(firstBarData.browser).toBe('Chrome');
180+
expect(firstBarData.downloads).toBe(27000);
181+
expect(firstBarData.percentLabel).toBe('53.1%');
182+
expect(firstBarData.rscMarkId).toBe(1);
183+
expect(firstBarData.downloads0).toBe(0);
184+
expect(firstBarData.downloads1).toBe(27000);
185+
expect(firstBarData.rscStackId).toBe('Chrome');
186+
187+
// Re-query bars after hover state change to get fresh DOM references
188+
const barsAfterHover = await findAllMarksByGroupName(chart, 'bar0');
189+
190+
// Unhover first bar
191+
await unhoverNthElement(barsAfterHover, 0);
192+
expect(screen.getByTestId('no-hover')).toBeInTheDocument();
193+
expect(screen.queryByTestId('hover-data')).not.toBeInTheDocument();
194+
195+
// Re-query bars after unhover state change for fresh DOM references
196+
const barsAfterUnhover = await findAllMarksByGroupName(chart, 'bar0');
197+
198+
// Hover over second bar (Firefox, 8000)
199+
await hoverNthElement(barsAfterUnhover, 1);
200+
201+
hoverData = screen.getByTestId('hover-data');
202+
expect(hoverData).toBeInTheDocument();
203+
204+
const secondBarData = JSON.parse(hoverData.textContent || '{}');
205+
expect(secondBarData.browser).toBe('Firefox');
206+
expect(secondBarData.downloads).toBe(8000);
207+
expect(secondBarData.percentLabel).toBe('15.7%');
208+
expect(secondBarData.rscMarkId).toBe(2);
209+
expect(secondBarData.downloads0).toBe(0);
210+
expect(secondBarData.downloads1).toBe(8000);
211+
expect(secondBarData.rscStackId).toBe('Firefox');
212+
});
131213
});

packages/react-spectrum-charts/src/types/marks/bar.types.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { JSXElementConstructor, ReactElement } from 'react';
1414
import { BarOptions } from '@spectrum-charts/vega-spec-builder';
1515

1616
import { ChartPopoverElement, ChartTooltipElement } from '../dialogs';
17-
import { Children, OnClickCallback } from '../util.types';
17+
import { Children, MarkCallback } from '../util.types';
1818
import { BarAnnotationElement, TrendlineElement } from './supplemental';
1919

2020
export interface BarProps
@@ -24,7 +24,11 @@ export interface BarProps
2424
> {
2525
children?: Children<BarAnnotationElement | ChartPopoverElement | ChartTooltipElement | TrendlineElement>;
2626
/** Callback that will be run when a point/section is clicked */
27-
onClick?: OnClickCallback;
27+
onClick?: MarkCallback;
28+
/** Callback that will be run when a point/section is hovered */
29+
onMouseOver?: MarkCallback;
30+
/** Callback that will be run when a point/section is no longer hovered */
31+
onMouseOut?: MarkCallback;
2832
}
2933

3034
export type BarElement = ReactElement<BarProps, JSXElementConstructor<BarProps>>;

packages/react-spectrum-charts/src/types/marks/line.types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { JSXElementConstructor, ReactElement } from 'react';
1414
import { LineOptions } from '@spectrum-charts/vega-spec-builder';
1515

1616
import { ChartPopoverElement, ChartTooltipElement } from '../dialogs';
17-
import { Children, OnClickCallback } from '../util.types';
17+
import { Children, MarkCallback } from '../util.types';
1818
import { MetricRangeElement, TrendlineElement } from './supplemental';
1919

2020
type LineChildElement = ChartTooltipElement | ChartPopoverElement | MetricRangeElement | TrendlineElement;
@@ -25,7 +25,7 @@ export interface LineProps
2525
> {
2626
children?: Children<LineChildElement>;
2727
/** Callback that will be run when a point/section is clicked */
28-
onClick?: OnClickCallback;
28+
onClick?: MarkCallback;
2929
}
3030

3131
export type LineElement = ReactElement<LineProps, JSXElementConstructor<LineProps>>;

packages/react-spectrum-charts/src/types/util.types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ import { Datum } from '@spectrum-charts/vega-spec-builder';
1515

1616
export type ChildElement<T> = T | string | boolean | Iterable<ReactNode>;
1717
export type Children<T> = ChildElement<T> | ChildElement<T>[];
18-
export type OnClickCallback = (datum: Datum) => void;
18+
export type MarkCallback = (datum: Datum) => void;
1919

2020
export interface ClickableChartProps {
2121
/** Callback that will be run when a point/section is clicked */
22-
onClick?: OnClickCallback;
22+
onClick?: MarkCallback;
2323
}

0 commit comments

Comments
 (0)