From 6fa0d207e568b57ad421b473f99fa22f737e388b Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:30:13 -0400 Subject: [PATCH] fix(charts): dismiss HBC callout on keyboard blur HorizontalBarChart's _hoverOff was emptied by the callout-flicker fix in PR #21750 but stayed wired to onBlur, so the callout only closed via mouse leave. Keyboard users tabbing out of a chart (or into a second chart) left the previous callout stuck open. The v9 port carried the same no-op with a ToDo comment. Revive blur dismissal in both packages, guarded by relatedTarget: focus moving within the chart root (or into the v8 Layer-portaled callout, looked up by callout id) does not dismiss, which keeps the PR #21750 flicker fix intact - locked in by guard tests that pass before and after the change. v8: full react-charting suite green (962 passed). v9: full react-charts suite matches baseline exactly (only the 6 pre-existing HeatMapChart snapshot failures remain). v8 VerticalBarChart has a sibling no-op _onBarLeave - follow-up candidate. Fixes #29925 --- ...-aaa5b2b3-e318-4744-b873-0715a1fc9cbe.json | 7 +++ ...-36931831-1583-4ee1-a8a1-d2de8523a2b1.json | 7 +++ .../HorizontalBarChart.base.tsx | 24 ++++++-- .../HorizontalBarChartRTL.test.tsx | 53 ++++++++++++++++++ .../HorizontalBarChart.test.tsx | 56 +++++++++++++++++++ .../HorizontalBarChart/HorizontalBarChart.tsx | 14 ++++- 6 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 change/@fluentui-react-charting-aaa5b2b3-e318-4744-b873-0715a1fc9cbe.json create mode 100644 change/@fluentui-react-charts-36931831-1583-4ee1-a8a1-d2de8523a2b1.json diff --git a/change/@fluentui-react-charting-aaa5b2b3-e318-4744-b873-0715a1fc9cbe.json b/change/@fluentui-react-charting-aaa5b2b3-e318-4744-b873-0715a1fc9cbe.json new file mode 100644 index 00000000000000..7a24b61adc328c --- /dev/null +++ b/change/@fluentui-react-charting-aaa5b2b3-e318-4744-b873-0715a1fc9cbe.json @@ -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" +} diff --git a/change/@fluentui-react-charts-36931831-1583-4ee1-a8a1-d2de8523a2b1.json b/change/@fluentui-react-charts-36931831-1583-4ee1-a8a1-d2de8523a2b1.json new file mode 100644 index 00000000000000..0b0b3908cb3eb2 --- /dev/null +++ b/change/@fluentui-react-charts-36931831-1583-4ee1-a8a1-d2de8523a2b1.json @@ -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" +} diff --git a/packages/charts/react-charting/src/components/HorizontalBarChart/HorizontalBarChart.base.tsx b/packages/charts/react-charting/src/components/HorizontalBarChart/HorizontalBarChart.base.tsx index 7d4de23c44a413..f95b5384d04174 100644 --- a/packages/charts/react-charting/src/components/HorizontalBarChart/HorizontalBarChart.base.tsx +++ b/packages/charts/react-charting/src/components/HorizontalBarChart/HorizontalBarChart.base.tsx @@ -49,6 +49,7 @@ export class HorizontalBarChartBase extends React.Component; private _emptyChartId: string; + private _rootRef: React.RefObject = React.createRef(); constructor(props: IHorizontalBarChartProps) { super(props); @@ -65,7 +66,6 @@ export class HorizontalBarChartBase extends React.Component(); @@ -87,7 +87,7 @@ export class HorizontalBarChartBase extends React.Component +
{data!.map((points: IChartProps, index: number) => { if (points.chartData && points.chartData![0] && points.chartData![0].horizontalBarChartdata!.x) { datapoint = points.chartData![0].horizontalBarChartdata!.x; @@ -229,9 +229,23 @@ export class HorizontalBarChartBase extends React.Component | React.MouseEvent): 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; diff --git a/packages/charts/react-charting/src/components/HorizontalBarChart/HorizontalBarChartRTL.test.tsx b/packages/charts/react-charting/src/components/HorizontalBarChart/HorizontalBarChartRTL.test.tsx index 2b870bc4476538..686f0146dfa72d 100644 --- a/packages/charts/react-charting/src/components/HorizontalBarChart/HorizontalBarChartRTL.test.tsx +++ b/packages/charts/react-charting/src/components/HorizontalBarChart/HorizontalBarChartRTL.test.tsx @@ -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( + <> + + + , + ); + 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(); + 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( + <> + + + , + ); + 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', () => { diff --git a/packages/charts/react-charts/library/src/components/HorizontalBarChart/HorizontalBarChart.test.tsx b/packages/charts/react-charts/library/src/components/HorizontalBarChart/HorizontalBarChart.test.tsx index 0f1ceac610b7fb..84b6e4a0fb3b1f 100644 --- a/packages/charts/react-charts/library/src/components/HorizontalBarChart/HorizontalBarChart.test.tsx +++ b/packages/charts/react-charts/library/src/components/HorizontalBarChart/HorizontalBarChart.test.tsx @@ -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( + <> + + + , + ); + 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(); + 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(); + }); +}); diff --git a/packages/charts/react-charts/library/src/components/HorizontalBarChart/HorizontalBarChart.tsx b/packages/charts/react-charts/library/src/components/HorizontalBarChart/HorizontalBarChart.tsx index 605185418615af..5ff88df8d36ea2 100644 --- a/packages/charts/react-charts/library/src/components/HorizontalBarChart/HorizontalBarChart.tsx +++ b/packages/charts/react-charts/library/src/components/HorizontalBarChart/HorizontalBarChart.tsx @@ -26,6 +26,7 @@ export const HorizontalBarChart: React.FunctionComponent((props, forwardedRef) => { const legendContainer = React.useRef(null); + const _rootRef = React.useRef(null); const _uniqLineText: string = useId('_HorizontalLine_'); const _refArray: RefArrayData[] = []; const _isRTL: boolean = useRtl(); @@ -88,8 +89,15 @@ export const HorizontalBarChart: React.FunctionComponent | React.MouseEvent): 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 = () => { @@ -390,7 +398,7 @@ export const HorizontalBarChart: React.FunctionComponent +
{data!.map((points: ChartProps, index: number) => { if (points.chartData && points.chartData![0] && points.chartData![0].horizontalBarChartdata!.x) { datapoint = points.chartData![0].horizontalBarChartdata!.x;