Skip to content

Commit f36ffdb

Browse files
authored
feat(bounding-box): keyboard navigation for bounding boxes list (#741)
* feat(bounding-box): keyboard navigation for bounding boxes list * feat(bounding-box): update stop propagation
1 parent b8fab7e commit f36ffdb

3 files changed

Lines changed: 112 additions & 4 deletions

File tree

src/components/BoundingBoxHighlight/BoundingBoxHighlightNav.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,34 @@ import './BoundingBoxHighlightNav.scss';
88
type Props = {
99
currentIndex: number;
1010
total: number;
11-
onPrev: (event: React.MouseEvent) => void;
12-
onNext: (event: React.MouseEvent) => void;
11+
onPrev: (event: React.MouseEvent | KeyboardEvent) => void;
12+
onNext: (event: React.MouseEvent | KeyboardEvent) => void;
1313
};
1414

1515
const BoundingBoxHighlightNav = ({ currentIndex, total, onPrev, onNext }: Props): JSX.Element => {
1616
const intl = useIntl();
1717
const isPrevDisabled = currentIndex === 0;
1818
const isNextDisabled = currentIndex === total - 1;
1919

20+
React.useEffect(() => {
21+
const handleKeyDown = (event: KeyboardEvent): void => {
22+
if (event.key === 'ArrowLeft') {
23+
event.stopPropagation();
24+
if (!isPrevDisabled) {
25+
onPrev(event);
26+
}
27+
} else if (event.key === 'ArrowRight') {
28+
event.stopPropagation();
29+
if (!isNextDisabled) {
30+
onNext(event);
31+
}
32+
}
33+
};
34+
35+
document.addEventListener('keydown', handleKeyDown, { capture: true });
36+
return () => document.removeEventListener('keydown', handleKeyDown, { capture: true });
37+
}, [isPrevDisabled, isNextDisabled, onPrev, onNext]);
38+
2039
return (
2140
<div className="ba-BoundingBoxHighlightNav" data-testid="ba-BoundingBoxHighlightNav">
2241
<button

src/components/BoundingBoxHighlight/BoundingBoxHighlightRect.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,14 @@ const BoundingBoxHighlightRect = ({
4040
onSelect?.(id);
4141
};
4242

43-
const handlePrev = (event: React.MouseEvent): void => {
43+
const handlePrev = (event: React.MouseEvent | KeyboardEvent): void => {
4444
event.stopPropagation();
4545
if (prevId) {
4646
onNavigate?.(prevId);
4747
}
4848
};
4949

50-
const handleNext = (event: React.MouseEvent): void => {
50+
const handleNext = (event: React.MouseEvent | KeyboardEvent): void => {
5151
event.stopPropagation();
5252
if (nextId) {
5353
onNavigate?.(nextId);

src/components/BoundingBoxHighlight/__tests__/BoundingBoxHighlightNav-test.tsx

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,95 @@ describe('BoundingBoxHighlightNav', () => {
116116
});
117117
});
118118

119+
describe('keyboard navigation', () => {
120+
test('should call onPrev when ArrowLeft is pressed', () => {
121+
const onPrev = jest.fn();
122+
renderNav({ currentIndex: 1, onPrev });
123+
124+
fireEvent.keyDown(document, { key: 'ArrowLeft' });
125+
126+
expect(onPrev).toHaveBeenCalledTimes(1);
127+
});
128+
129+
test('should call onNext when ArrowRight is pressed', () => {
130+
const onNext = jest.fn();
131+
renderNav({ currentIndex: 1, onNext });
132+
133+
fireEvent.keyDown(document, { key: 'ArrowRight' });
134+
135+
expect(onNext).toHaveBeenCalledTimes(1);
136+
});
137+
138+
test('should not call onPrev when ArrowLeft is pressed and at first item', () => {
139+
const onPrev = jest.fn();
140+
renderNav({ currentIndex: 0, onPrev });
141+
142+
fireEvent.keyDown(document, { key: 'ArrowLeft' });
143+
144+
expect(onPrev).not.toHaveBeenCalled();
145+
});
146+
147+
test('should not call onNext when ArrowRight is pressed and at last item', () => {
148+
const onNext = jest.fn();
149+
renderNav({ currentIndex: 4, total: 5, onNext });
150+
151+
fireEvent.keyDown(document, { key: 'ArrowRight' });
152+
153+
expect(onNext).not.toHaveBeenCalled();
154+
});
155+
156+
test('should stop propagation for ArrowLeft even when prev is disabled', () => {
157+
renderNav({ currentIndex: 0 });
158+
const event = new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true });
159+
jest.spyOn(event, 'stopPropagation');
160+
161+
document.dispatchEvent(event);
162+
163+
expect(event.stopPropagation).toHaveBeenCalled();
164+
});
165+
166+
test('should stop propagation for ArrowRight even when next is disabled', () => {
167+
renderNav({ currentIndex: 4, total: 5 });
168+
const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true });
169+
jest.spyOn(event, 'stopPropagation');
170+
171+
document.dispatchEvent(event);
172+
173+
expect(event.stopPropagation).toHaveBeenCalled();
174+
});
175+
176+
test('should not stop propagation for non-arrow keys', () => {
177+
renderNav({ currentIndex: 1 });
178+
const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true });
179+
jest.spyOn(event, 'stopPropagation');
180+
181+
document.dispatchEvent(event);
182+
183+
expect(event.stopPropagation).not.toHaveBeenCalled();
184+
});
185+
186+
test('should not call callbacks for non-arrow keys', () => {
187+
const onPrev = jest.fn();
188+
const onNext = jest.fn();
189+
renderNav({ currentIndex: 1, onPrev, onNext });
190+
191+
fireEvent.keyDown(document, { key: 'Enter' });
192+
193+
expect(onPrev).not.toHaveBeenCalled();
194+
expect(onNext).not.toHaveBeenCalled();
195+
});
196+
197+
test('should remove keydown listener on unmount', () => {
198+
const onPrev = jest.fn();
199+
const { unmount } = renderNav({ currentIndex: 1, onPrev });
200+
201+
unmount();
202+
fireEvent.keyDown(document, { key: 'ArrowLeft' });
203+
204+
expect(onPrev).not.toHaveBeenCalled();
205+
});
206+
});
207+
119208
describe('accessibility', () => {
120209
test('should have aria-label on prev button', () => {
121210
renderNav();

0 commit comments

Comments
 (0)