Skip to content

fix: restore arrow key navigation when active index is outside zoomed…#7086

Merged
PavelVanecek merged 1 commit intorecharts:mainfrom
AbishekRaj2007:fix/zoom-keyboard-navigation
Mar 7, 2026
Merged

fix: restore arrow key navigation when active index is outside zoomed…#7086
PavelVanecek merged 1 commit intorecharts:mainfrom
AbishekRaj2007:fix/zoom-keyboard-navigation

Conversation

@AbishekRaj2007
Copy link
Copy Markdown
Contributor

@AbishekRaj2007 AbishekRaj2007 commented Mar 4, 2026

Description

When using the Highlight and Zoom Line Chart, pressing the arrow keys to
navigate to a data point outside the visible/zoomed domain caused keyboard
navigation to break entirely. Once the active index moved outside the domain,
neither ArrowLeft nor ArrowRight would respond, leaving the user stuck.

Root Cause

The arrow key handler was updating activeIndex without checking whether
the new index fell within the current visible domain. Once outside, the
navigation logic had no way to recover back into the zoomed range.

Fix

Added a domain boundary check in the keyboard navigation logic so that:

  • Arrow keys clamp/skip to indices within the current visible domain
  • If activeIndex is outside the domain, ArrowLeft brings it back to
    the nearest visible index on the left
  • Navigation never gets stuck outside the zoomed viewport

Steps to Reproduce (original bug)

  1. Use the Highlight and Zoom Line Chart example
  2. Zoom into a section using click and drag
  3. Focus the chart with Tab and press ArrowRight to move outside visible domain
  4. Press ArrowLeft — arrow keys stop working entirely

Testing

  • Zooming in and navigating with arrow keys stays within visible domain
  • ArrowLeft correctly returns to visible points from outside the domain
  • ArrowRight stops at the last visible point without getting stuck
  • Verified against the original reproduction:
    https://stackblitz.com/edit/jmoysgu8?file=src%2FExample.tsx

Related Issue

Fixes #7055

Version

Tested on Recharts 3.7.0

Summary by CodeRabbit

  • New Features

    • Enhanced keyboard navigation to gracefully handle cases outside the visible data range. Arrow keys now snap to the nearest valid data point within the visible domain.
    • Improved chart accessibility with better keyboard interaction support, particularly after zoom operations.
  • Tests

    • Added accessibility tests for keyboard navigation on charts, verifying arrow-key behavior in zoomed states.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 4, 2026

Walkthrough

This PR addresses keyboard navigation failures when charts are zoomed. The middleware now detects when the current tooltip index falls outside the zoomed domain and attempts to locate the nearest valid index within visible data using existing validation logic, while preserving the original navigation behavior for valid domain indices.

Changes

Cohort / File(s) Summary
Keyboard Navigation Enhancement
src/state/keyboardEventsMiddleware.ts
Added domain-awareness to keyboard navigation by importing TooltipInteractionState and introducing isOutsideDomain flag. Modified Enter key handling to early-return when outside domain. For arrow navigation outside domain, scans displayed data to find nearest valid index via combineActiveTooltipIndex; retains existing behavior for valid domain indices.
Accessibility Tests & Public Exports
test/chart/AccessibilityLayer.spec.tsx, src/index.ts
Added public exports for Line and LineChart components. Added three keyboard interaction tests verifying that arrow key navigation snaps back into the visible domain after zoom: left arrow to last visible tick, right arrow to first visible tick, and subsequent normal navigation from snapped position.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • PavelVanecek
  • ckifer
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the specific bug fix: restoring arrow key navigation when the active index is outside the zoomed domain, which is the main objective of the PR.
Description check ✅ Passed The description covers all major template sections: problem description, root cause, fix explanation, reproduction steps, testing approach, related issue, and version info. It comprehensively explains the change.
Linked Issues check ✅ Passed The code changes fully address issue #7055: domain boundary checks prevent activeIndex from getting stuck outside the visible domain, arrow keys now work when index is outside domain, and navigation can recover back into the viewport.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing keyboard navigation in zoomed charts: domain logic in keyboardEventsMiddleware, new test coverage, and necessary exports (Line, LineChart) to enable testing.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/chart/AccessibilityLayer.spec.tsx`:
- Around line 469-526: These tests use loose membership checks; update them to
assert exact snap targets: in the "ArrowLeft snaps..." test, after
rerender(<ZoomableChart domain={[3, 6]} />) expect getTooltip(container) to
equal the last visible tick value (assert exactly '60'); in the "ArrowRight
snaps..." test expect getTooltip(container) to equal the first visible tick
value (assert exactly '30'); in the "After snapping..." test, after the first
arrowLeft assert exactly '60', after the next arrowLeft assert exactly '50', and
after arrowRight assert exactly '60'. Locate usages of render/rerender with
ZoomableChart, arrowRight/arrowLeft calls and getTooltip assertions to make
these precise equality checks.
- Around line 450-527: The tests in the "arrow keys snap back into visible
domain after zoom (numeric XAxis)" describe block rely on RAF/timers but don't
use fake timers; add vi.useFakeTimers() in a beforeEach inside that describe and
restore with vi.useRealTimers() in an afterEach, and after every simulated key
helper call (arrowRight/arrowLeft) invoke vi.runOnlyPendingTimers() so tooltip
updates driven by requestAnimationFrame settle before assertions; update
references in the tests that call arrowRight/arrowLeft (and any loops that call
them) to run pending timers after each call to ensure getTooltip() returns
stable values for assertions on ZoomableChart and its tooltip behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 66f3b246-df2f-4dbf-937c-a000ecc09213

📥 Commits

Reviewing files that changed from the base of the PR and between 473d55c and 8375fc4.

📒 Files selected for processing (2)
  • src/state/keyboardEventsMiddleware.ts
  • test/chart/AccessibilityLayer.spec.tsx

Comment on lines +450 to +527
describe('arrow keys snap back into visible domain after zoom (numeric XAxis)', () => {
const numericData = [
{ name: 1, uv: 10 },
{ name: 2, uv: 20 },
{ name: 3, uv: 30 },
{ name: 4, uv: 40 },
{ name: 5, uv: 50 },
{ name: 6, uv: 60 },
{ name: 7, uv: 70 },
{ name: 8, uv: 80 },
];
const ZoomableChart = ({ domain }: { domain?: [number, number] }) => (
<LineChart width={500} height={300} data={numericData} accessibilityLayer={accessibilityLayer}>
<Line dataKey="uv" />
<Tooltip />
<XAxis dataKey="name" type="number" domain={domain ?? [1, 8]} allowDataOverflow />
<YAxis />
</LineChart>
);
test('ArrowLeft snaps from an outside-domain index to the last visible tick', () => {
const { container, rerender } = render(<ZoomableChart />);
const svg = getMainSurface(container);

act(() => svg.focus());

for (let i = 0; i < 6; i++) {
arrowRight(svg);
}
expect(getTooltip(container)).toHaveTextContent('70'); // uv of name=7

rerender(<ZoomableChart domain={[3, 6]} />);

arrowLeft(svg);
const tooltipText = getTooltip(container).textContent ?? '';
expect(['30', '40', '50', '60'].some(v => tooltipText.includes(v))).toBe(true);
});

test('ArrowRight snaps from an outside-domain index to the first visible tick', () => {
const { container, rerender } = render(<ZoomableChart />);
const svg = getMainSurface(container);

act(() => svg.focus());

for (let i = 0; i < 6; i++) {
arrowRight(svg);
}
expect(getTooltip(container)).toHaveTextContent('70');

rerender(<ZoomableChart domain={[3, 6]} />);

arrowRight(svg);
const tooltipText = getTooltip(container).textContent ?? '';
expect(['30', '40', '50', '60'].some(v => tooltipText.includes(v))).toBe(true);
});

test('After snapping into domain, subsequent arrow presses navigate normally', () => {
const { container, rerender } = render(<ZoomableChart />);
const svg = getMainSurface(container);

act(() => svg.focus());

for (let i = 0; i < 6; i++) {
arrowRight(svg);
}

rerender(<ZoomableChart domain={[3, 6]} />);

arrowLeft(svg);
const afterSnapText = getTooltip(container).textContent ?? '';
expect(['30', '40', '50', '60'].some(v => afterSnapText.includes(v))).toBe(true);

arrowLeft(svg);
const textAfter = getTooltip(container).textContent ?? '';
expect(['30', '40', '50', '60'].some(v => textAfter.includes(v))).toBe(true);
arrowRight(svg);
expect(['30', '40', '50', '60'].some(v => (getTooltip(container).textContent ?? '').includes(v))).toBe(true);
});
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check timer usage in the entire spec file
rg -n "vi\.(useFakeTimers|runOnlyPendingTimers)" test/chart/AccessibilityLayer.spec.tsx | head -20

Repository: recharts/recharts

Length of output: 43


🏁 Script executed:

# Get context around the test block to see if it has timer setup
sed -n '440,535p' test/chart/AccessibilityLayer.spec.tsx

Repository: recharts/recharts

Length of output: 3453


🏁 Script executed:

# Check the start of the file for timer configuration
head -60 test/chart/AccessibilityLayer.spec.tsx

Repository: recharts/recharts

Length of output: 1885


Add fake timers and pending-timer flushing to this test block.

Per coding guidelines, all tests in test/**/*.{test,spec}.{ts,tsx} must use vi.useFakeTimers() due to Redux autoBatchEnhancer dependency on timers and requestAnimationFrame. Since these tests involve keyboard navigation and tooltip updates driven by RAF, add vi.useFakeTimers() in a beforeEach hook and call vi.runOnlyPendingTimers() after each arrowRight() or arrowLeft() call to ensure tooltip state updates propagate correctly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/chart/AccessibilityLayer.spec.tsx` around lines 450 - 527, The tests in
the "arrow keys snap back into visible domain after zoom (numeric XAxis)"
describe block rely on RAF/timers but don't use fake timers; add
vi.useFakeTimers() in a beforeEach inside that describe and restore with
vi.useRealTimers() in an afterEach, and after every simulated key helper call
(arrowRight/arrowLeft) invoke vi.runOnlyPendingTimers() so tooltip updates
driven by requestAnimationFrame settle before assertions; update references in
the tests that call arrowRight/arrowLeft (and any loops that call them) to run
pending timers after each call to ensure getTooltip() returns stable values for
assertions on ZoomableChart and its tooltip behavior.

Comment on lines +469 to +526
test('ArrowLeft snaps from an outside-domain index to the last visible tick', () => {
const { container, rerender } = render(<ZoomableChart />);
const svg = getMainSurface(container);

act(() => svg.focus());

for (let i = 0; i < 6; i++) {
arrowRight(svg);
}
expect(getTooltip(container)).toHaveTextContent('70'); // uv of name=7

rerender(<ZoomableChart domain={[3, 6]} />);

arrowLeft(svg);
const tooltipText = getTooltip(container).textContent ?? '';
expect(['30', '40', '50', '60'].some(v => tooltipText.includes(v))).toBe(true);
});

test('ArrowRight snaps from an outside-domain index to the first visible tick', () => {
const { container, rerender } = render(<ZoomableChart />);
const svg = getMainSurface(container);

act(() => svg.focus());

for (let i = 0; i < 6; i++) {
arrowRight(svg);
}
expect(getTooltip(container)).toHaveTextContent('70');

rerender(<ZoomableChart domain={[3, 6]} />);

arrowRight(svg);
const tooltipText = getTooltip(container).textContent ?? '';
expect(['30', '40', '50', '60'].some(v => tooltipText.includes(v))).toBe(true);
});

test('After snapping into domain, subsequent arrow presses navigate normally', () => {
const { container, rerender } = render(<ZoomableChart />);
const svg = getMainSurface(container);

act(() => svg.focus());

for (let i = 0; i < 6; i++) {
arrowRight(svg);
}

rerender(<ZoomableChart domain={[3, 6]} />);

arrowLeft(svg);
const afterSnapText = getTooltip(container).textContent ?? '';
expect(['30', '40', '50', '60'].some(v => afterSnapText.includes(v))).toBe(true);

arrowLeft(svg);
const textAfter = getTooltip(container).textContent ?? '';
expect(['30', '40', '50', '60'].some(v => textAfter.includes(v))).toBe(true);
arrowRight(svg);
expect(['30', '40', '50', '60'].some(v => (getTooltip(container).textContent ?? '').includes(v))).toBe(true);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Tighten these assertions to verify exact snap targets, not “any visible point.”

The current checks can pass even if ArrowLeft/ArrowRight snap to the wrong boundary or if subsequent navigation regresses.

🎯 Suggested assertion hardening
       test('ArrowLeft snaps from an outside-domain index to the last visible tick', () => {
@@
         rerender(<ZoomableChart domain={[3, 6]} />);
 
         arrowLeft(svg);
-        const tooltipText = getTooltip(container).textContent ?? '';
-        expect(['30', '40', '50', '60'].some(v => tooltipText.includes(v))).toBe(true);
+        expect(getTooltip(container)).toHaveTextContent('60');
       });
@@
       test('ArrowRight snaps from an outside-domain index to the first visible tick', () => {
@@
         rerender(<ZoomableChart domain={[3, 6]} />);
 
         arrowRight(svg);
-        const tooltipText = getTooltip(container).textContent ?? '';
-        expect(['30', '40', '50', '60'].some(v => tooltipText.includes(v))).toBe(true);
+        expect(getTooltip(container)).toHaveTextContent('30');
       });
@@
       test('After snapping into domain, subsequent arrow presses navigate normally', () => {
@@
         rerender(<ZoomableChart domain={[3, 6]} />);
 
         arrowLeft(svg);
-        const afterSnapText = getTooltip(container).textContent ?? '';
-        expect(['30', '40', '50', '60'].some(v => afterSnapText.includes(v))).toBe(true);
+        expect(getTooltip(container)).toHaveTextContent('60');
 
         arrowLeft(svg);
-        const textAfter = getTooltip(container).textContent ?? '';
-        expect(['30', '40', '50', '60'].some(v => textAfter.includes(v))).toBe(true);
+        expect(getTooltip(container)).toHaveTextContent('50');
         arrowRight(svg);
-        expect(['30', '40', '50', '60'].some(v => (getTooltip(container).textContent ?? '').includes(v))).toBe(true);
+        expect(getTooltip(container)).toHaveTextContent('60');
       });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('ArrowLeft snaps from an outside-domain index to the last visible tick', () => {
const { container, rerender } = render(<ZoomableChart />);
const svg = getMainSurface(container);
act(() => svg.focus());
for (let i = 0; i < 6; i++) {
arrowRight(svg);
}
expect(getTooltip(container)).toHaveTextContent('70'); // uv of name=7
rerender(<ZoomableChart domain={[3, 6]} />);
arrowLeft(svg);
const tooltipText = getTooltip(container).textContent ?? '';
expect(['30', '40', '50', '60'].some(v => tooltipText.includes(v))).toBe(true);
});
test('ArrowRight snaps from an outside-domain index to the first visible tick', () => {
const { container, rerender } = render(<ZoomableChart />);
const svg = getMainSurface(container);
act(() => svg.focus());
for (let i = 0; i < 6; i++) {
arrowRight(svg);
}
expect(getTooltip(container)).toHaveTextContent('70');
rerender(<ZoomableChart domain={[3, 6]} />);
arrowRight(svg);
const tooltipText = getTooltip(container).textContent ?? '';
expect(['30', '40', '50', '60'].some(v => tooltipText.includes(v))).toBe(true);
});
test('After snapping into domain, subsequent arrow presses navigate normally', () => {
const { container, rerender } = render(<ZoomableChart />);
const svg = getMainSurface(container);
act(() => svg.focus());
for (let i = 0; i < 6; i++) {
arrowRight(svg);
}
rerender(<ZoomableChart domain={[3, 6]} />);
arrowLeft(svg);
const afterSnapText = getTooltip(container).textContent ?? '';
expect(['30', '40', '50', '60'].some(v => afterSnapText.includes(v))).toBe(true);
arrowLeft(svg);
const textAfter = getTooltip(container).textContent ?? '';
expect(['30', '40', '50', '60'].some(v => textAfter.includes(v))).toBe(true);
arrowRight(svg);
expect(['30', '40', '50', '60'].some(v => (getTooltip(container).textContent ?? '').includes(v))).toBe(true);
});
test('ArrowLeft snaps from an outside-domain index to the last visible tick', () => {
const { container, rerender } = render(<ZoomableChart />);
const svg = getMainSurface(container);
act(() => svg.focus());
for (let i = 0; i < 6; i++) {
arrowRight(svg);
}
expect(getTooltip(container)).toHaveTextContent('70'); // uv of name=7
rerender(<ZoomableChart domain={[3, 6]} />);
arrowLeft(svg);
expect(getTooltip(container)).toHaveTextContent('60');
});
test('ArrowRight snaps from an outside-domain index to the first visible tick', () => {
const { container, rerender } = render(<ZoomableChart />);
const svg = getMainSurface(container);
act(() => svg.focus());
for (let i = 0; i < 6; i++) {
arrowRight(svg);
}
expect(getTooltip(container)).toHaveTextContent('70');
rerender(<ZoomableChart domain={[3, 6]} />);
arrowRight(svg);
expect(getTooltip(container)).toHaveTextContent('30');
});
test('After snapping into domain, subsequent arrow presses navigate normally', () => {
const { container, rerender } = render(<ZoomableChart />);
const svg = getMainSurface(container);
act(() => svg.focus());
for (let i = 0; i < 6; i++) {
arrowRight(svg);
}
rerender(<ZoomableChart domain={[3, 6]} />);
arrowLeft(svg);
expect(getTooltip(container)).toHaveTextContent('60');
arrowLeft(svg);
expect(getTooltip(container)).toHaveTextContent('50');
arrowRight(svg);
expect(getTooltip(container)).toHaveTextContent('60');
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/chart/AccessibilityLayer.spec.tsx` around lines 469 - 526, These tests
use loose membership checks; update them to assert exact snap targets: in the
"ArrowLeft snaps..." test, after rerender(<ZoomableChart domain={[3, 6]} />)
expect getTooltip(container) to equal the last visible tick value (assert
exactly '60'); in the "ArrowRight snaps..." test expect getTooltip(container) to
equal the first visible tick value (assert exactly '30'); in the "After
snapping..." test, after the first arrowLeft assert exactly '60', after the next
arrowLeft assert exactly '50', and after arrowRight assert exactly '60'. Locate
usages of render/rerender with ZoomableChart, arrowRight/arrowLeft calls and
getTooltip assertions to make these precise equality checks.

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 4, 2026

Codecov Report

❌ Patch coverage is 92.85714% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.58%. Comparing base (473d55c) to head (8375fc4).
⚠️ Report is 15 commits behind head on main.

Files with missing lines Patch % Lines
src/state/keyboardEventsMiddleware.ts 92.85% 3 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #7086   +/-   ##
=======================================
  Coverage   89.58%   89.58%           
=======================================
  Files         535      535           
  Lines       40188    40223   +35     
  Branches     5464     5472    +8     
=======================================
+ Hits        36001    36033   +32     
- Misses       4178     4181    +3     
  Partials        9        9           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@PavelVanecek PavelVanecek merged commit 4459048 into recharts:main Mar 7, 2026
41 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Keyboard navigation breaks when zoomed in

3 participants