fix: restore arrow key navigation when active index is outside zoomed…#7086
Conversation
WalkthroughThis 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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Tip Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs). 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. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
src/state/keyboardEventsMiddleware.tstest/chart/AccessibilityLayer.spec.tsx
| 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); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Check timer usage in the entire spec file
rg -n "vi\.(useFakeTimers|runOnlyPendingTimers)" test/chart/AccessibilityLayer.spec.tsx | head -20Repository: 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.tsxRepository: recharts/recharts
Length of output: 3453
🏁 Script executed:
# Check the start of the file for timer configuration
head -60 test/chart/AccessibilityLayer.spec.tsxRepository: 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.
| 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); | ||
| }); |
There was a problem hiding this comment.
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.
| 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 Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
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
activeIndexwithout checking whetherthe 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:
activeIndexis outside the domain, ArrowLeft brings it back tothe nearest visible index on the left
Steps to Reproduce (original bug)
Testing
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
Tests