Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: dismiss HorizontalBarChart callout when keyboard focus leaves the chart",
"packageName": "@fluentui/react-charting",
"email": "144495202+AKnassa@users.noreply.github.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: dismiss HorizontalBarChart popover when keyboard focus leaves the chart",
"packageName": "@fluentui/react-charts",
"email": "144495202+AKnassa@users.noreply.github.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export class HorizontalBarChartBase extends React.Component<IHorizontalBarChartP
private _isRTL: boolean = getRTL();
private barChartSvgRef: React.RefObject<SVGSVGElement | null>;
private _emptyChartId: string;
private _rootRef: React.RefObject<HTMLDivElement | null> = React.createRef<HTMLDivElement>();

constructor(props: IHorizontalBarChartProps) {
super(props);
Expand All @@ -65,7 +66,6 @@ export class HorizontalBarChartBase extends React.Component<IHorizontalBarChartP
};
this._refArray = [];
this._uniqLineText = '_HorizontalLine_' + Math.random().toString(36).substring(7);
this._hoverOff = this._hoverOff.bind(this);
this._calloutId = getId('callout');
this._emptyChartId = getId('_HBC_empty');
this.barChartSvgRef = React.createRef<SVGSVGElement>();
Expand All @@ -87,7 +87,7 @@ export class HorizontalBarChartBase extends React.Component<IHorizontalBarChartP
let datapoint: number | undefined = 0;

return !this._isChartEmpty() ? (
<div className={this._classNames.root} onMouseLeave={this._handleChartMouseLeave}>
<div className={this._classNames.root} onMouseLeave={this._handleChartMouseLeave} ref={this._rootRef}>
{data!.map((points: IChartProps, index: number) => {
if (points.chartData && points.chartData![0] && points.chartData![0].horizontalBarChartdata!.x) {
datapoint = points.chartData![0].horizontalBarChartdata!.x;
Expand Down Expand Up @@ -229,9 +229,23 @@ export class HorizontalBarChartBase extends React.Component<IHorizontalBarChartP
}
}

private _hoverOff(): void {
/**/
}
private _hoverOff = (event: React.FocusEvent<SVGRectElement> | React.MouseEvent<SVGRectElement>): void => {
const relatedTarget = event.relatedTarget as Element | null;
if (relatedTarget) {
// Keep the callout open when focus (or the pointer) moves to another element inside the
// chart, e.g. tabbing between bars. Dismissing here would reintroduce the callout
// flicker that PR #21750 fixed by emptying this handler.
if (this._rootRef.current?.contains(relatedTarget)) {
return;
}
// Also keep it open when focus moves into the callout itself (rendered in a Layer portal).
const calloutElement = relatedTarget.ownerDocument?.getElementById(this._calloutId);
if (calloutElement?.contains(relatedTarget)) {
return;
}
}
this._handleChartMouseLeave();
};

private _handleChartMouseLeave = () => {
this._calloutAnchorPoint = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,59 @@ describe('Horizontal bar chart - Subcomponent callout', () => {
expect(screen.queryByText('Custom Callout Content')).toBeDefined();
},
);

test('Should dismiss the callout when keyboard focus leaves the chart', async () => {
// Arrange
const { container } = render(
<>
<HorizontalBarChart data={chartPoints} calloutProps={{ doNotLayer: true }} />
<button data-testid="outside-btn">outside</button>
</>,
);
const bars = getByClass(container, /barWrapper-/i);
fireEvent.focus(bars[0]);
const callout = container.querySelector('.ms-Callout-container') as HTMLElement;
expect(callout).not.toBeNull();
await waitFor(() => expect(callout.style.visibility).not.toBe('hidden'));
// Act - move keyboard focus to an element outside the chart
fireEvent.blur(bars[0], { relatedTarget: screen.getByTestId('outside-btn') });
// Assert
await waitFor(() => expect(callout.style.visibility).toBe('hidden'));
});

test('Should keep the callout open when focus moves to another bar within the same chart', async () => {
// Arrange
const { container } = render(<HorizontalBarChart data={chartPoints} calloutProps={{ doNotLayer: true }} />);
const bars = getByClass(container, /barWrapper-/i);
fireEvent.focus(bars[0]);
const callout = container.querySelector('.ms-Callout-container') as HTMLElement;
expect(callout).not.toBeNull();
await waitFor(() => expect(callout.style.visibility).not.toBe('hidden'));
// Act - move keyboard focus to a bar of another series in the same chart
fireEvent.blur(bars[0], { relatedTarget: bars[2] });
// Assert - dismissing here would reintroduce the callout flicker fixed by PR #21750
await waitFor(() => expect(callout.style.visibility).not.toBe('hidden'));
});

test('Should dismiss the callout when keyboard focus moves to a bar in another chart', async () => {
// Arrange - two charts on the same page, as in the issue repro
const { container } = render(
<>
<HorizontalBarChart data={chartPoints} calloutProps={{ doNotLayer: true }} />
<HorizontalBarChart data={chartPointsWithBenchMark} calloutProps={{ doNotLayer: true }} />
</>,
);
const bars = getByClass(container, /barWrapper-/i);
expect(bars.length).toBe(12);
const firstChartCallout = container.querySelectorAll('.ms-Callout-container')[0] as HTMLElement;
expect(firstChartCallout).not.toBeNull();
fireEvent.focus(bars[0]);
await waitFor(() => expect(firstChartCallout.style.visibility).not.toBe('hidden'));
// Act - tab from the first chart's bar into the second chart
fireEvent.blur(bars[0], { relatedTarget: bars[6] });
// Assert
await waitFor(() => expect(firstChartCallout.style.visibility).toBe('hidden'));
});
});

describe('Horizontal bar chart - Screen resolution', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -545,3 +545,59 @@ describe('Render empty chart aria label div when chart is empty', () => {
expect(renderedDOM!.length).toBe(1);
});
});

// NOTE: keep this describe last in the file - its renders advance the global useId counter,
// which would churn the generated ids inside the snapshot tests above.
describe('Horizontal bar chart - keyboard popover dismissal', () => {
const originalGetBoundingClientRect = window.Element.prototype.getBoundingClientRect;
beforeEach(() => {
// The focus path of _hoverOn positions the popover from the bar's bounding rect and only
// opens it when the position moves beyond a 1px threshold from {0, 0}. jsdom returns
// all-zero rects, so mock a real geometry to let the popover open on focus.
window.Element.prototype.getBoundingClientRect = jest.fn().mockReturnValue({
bottom: 44,
height: 10,
left: 30,
right: 130,
top: 34,
width: 100,
x: 30,
y: 34,
toJSON: () => '',
} as DOMRect);
});
afterEach(() => {
window.Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
});

test('Should dismiss the popover when keyboard focus leaves the chart', async () => {
const { container } = render(
<>
<HorizontalBarChart data={chartPoints} />
<button data-testid="outside-btn">outside</button>
</>,
);
const bars = getByClass(container, /barWrapper/);
await act(() => {
fireEvent.focus(bars[0]);
});
expect(getByClass(container, /PopoverSurface/i)[0]).toBeDefined();
await act(() => {
fireEvent.blur(bars[0], { relatedTarget: screen.getByTestId('outside-btn') });
});
expect(getByClass(container, /PopoverSurface/i)[0]).toBeUndefined();
});

test('Should keep the popover open when focus moves to another bar within the same chart', async () => {
const { container } = render(<HorizontalBarChart data={chartPoints} />);
const bars = getByClass(container, /barWrapper/);
await act(() => {
fireEvent.focus(bars[0]);
});
expect(getByClass(container, /PopoverSurface/i)[0]).toBeDefined();
await act(() => {
fireEvent.blur(bars[0], { relatedTarget: bars[2] });
});
expect(getByClass(container, /PopoverSurface/i)[0]).toBeDefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const HorizontalBarChart: React.FunctionComponent<HorizontalBarChartProps
HorizontalBarChartProps
>((props, forwardedRef) => {
const legendContainer = React.useRef<HTMLDivElement | null>(null);
const _rootRef = React.useRef<HTMLDivElement | null>(null);
const _uniqLineText: string = useId('_HorizontalLine_');
const _refArray: RefArrayData[] = [];
const _isRTL: boolean = useRtl();
Expand Down Expand Up @@ -88,8 +89,15 @@ export const HorizontalBarChart: React.FunctionComponent<HorizontalBarChartProps
}
}

function _hoverOff(): void {
/*ToDo. To fix*/
function _hoverOff(event: React.FocusEvent<SVGRectElement> | React.MouseEvent<SVGRectElement>): void {
const relatedTarget = event.relatedTarget as Node | null;
// Keep the popover open while focus (or the pointer) stays inside the chart, e.g. when
// tabbing between bars. This also covers the inline ChartPopover, which renders within
// the chart root. Dismiss only when focus leaves the chart.
if (relatedTarget && _rootRef.current?.contains(relatedTarget)) {
return;
}
_handleChartMouseLeave();
}

const _handleChartMouseLeave = () => {
Expand Down Expand Up @@ -390,7 +398,7 @@ export const HorizontalBarChart: React.FunctionComponent<HorizontalBarChartProps

let datapoint: number | undefined = 0;
return !_isChartEmpty() ? (
<div className={classes.root} onMouseLeave={_handleChartMouseLeave}>
<div className={classes.root} onMouseLeave={_handleChartMouseLeave} ref={_rootRef}>
{data!.map((points: ChartProps, index: number) => {
if (points.chartData && points.chartData![0] && points.chartData![0].horizontalBarChartdata!.x) {
datapoint = points.chartData![0].horizontalBarChartdata!.x;
Expand Down