diff --git a/src/components/BoundingBoxHighlight/BoundingBoxHighlightList.scss b/src/components/BoundingBoxHighlight/BoundingBoxHighlightList.scss
new file mode 100644
index 000000000..b0c7dd0ae
--- /dev/null
+++ b/src/components/BoundingBoxHighlight/BoundingBoxHighlightList.scss
@@ -0,0 +1,8 @@
+.ba-BoundingBoxHighlightList {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+}
diff --git a/src/components/BoundingBoxHighlight/BoundingBoxHighlightList.tsx b/src/components/BoundingBoxHighlight/BoundingBoxHighlightList.tsx
new file mode 100644
index 000000000..b45a98fec
--- /dev/null
+++ b/src/components/BoundingBoxHighlight/BoundingBoxHighlightList.tsx
@@ -0,0 +1,67 @@
+import * as React from 'react';
+import BoundingBoxHighlightRect from './BoundingBoxHighlightRect';
+import { BoundingBox } from './types';
+import './BoundingBoxHighlightList.scss';
+
+type Props = {
+ /**
+ * All bounding box highlights in the document.
+ */
+ allBoundingBoxes: BoundingBox[];
+ /**
+ * Bounding boxes on the current page.
+ */
+ boundingBoxes: BoundingBox[];
+ /**
+ * Callback invoked to navigate to the previous or next bounding box highlight.
+ */
+ onNavigate?: (id: string) => void;
+ /**
+ * Callback invoked when the user clicks on a bounding box highlight.
+ */
+ onSelect?: (id: string) => void;
+ /**
+ * The ID of the selected bounding box highlight.
+ */
+ selectedId: string | null;
+};
+
+const BoundingBoxHighlightList = ({
+ allBoundingBoxes,
+ boundingBoxes,
+ onNavigate,
+ onSelect,
+ selectedId,
+}: Props): React.ReactElement | null => {
+ const total = allBoundingBoxes.length;
+
+ if (total === 0) {
+ return null;
+ }
+
+ const selectedIndex = allBoundingBoxes.findIndex(h => h.id === selectedId);
+ const prevId = selectedIndex > 0 ? allBoundingBoxes[selectedIndex - 1].id : undefined;
+ const nextId = selectedIndex < total - 1 ? allBoundingBoxes[selectedIndex + 1].id : undefined;
+
+ return (
+
+ {boundingBoxes.map(bbox => {
+ return (
+
+ );
+ })}
+
+ );
+};
+
+export default React.memo(BoundingBoxHighlightList);
diff --git a/src/components/BoundingBoxHighlight/BoundingBoxHighlightNav.scss b/src/components/BoundingBoxHighlight/BoundingBoxHighlightNav.scss
new file mode 100644
index 000000000..1b33f727c
--- /dev/null
+++ b/src/components/BoundingBoxHighlight/BoundingBoxHighlightNav.scss
@@ -0,0 +1,73 @@
+$ba-BoundingBoxHighlightNav-bg: #fff;
+$ba-BoundingBoxHighlightNav-shadow: 0 6px 20px rgba(0, 0, 0, .1);
+$ba-BoundingBoxHighlightNav-arrow: #909090;
+$ba-BoundingBoxHighlightNav-border: #e8e8e8;
+$ba-BoundingBoxHighlightNav-counter: #222;
+
+.ba-BoundingBoxHighlightNav {
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ z-index: 1;
+ display: flex;
+ gap: 4px;
+ align-items: center;
+ margin-bottom: 6px;
+ padding: 3px;
+ white-space: nowrap;
+ background: $ba-BoundingBoxHighlightNav-bg;
+ border: 1px solid $ba-BoundingBoxHighlightNav-border;
+ border-radius: 20px;
+ box-shadow: $ba-BoundingBoxHighlightNav-shadow;
+ transform: translateX(-50%);
+ cursor: default;
+ pointer-events: auto;
+}
+
+.ba-BoundingBoxHighlightNav-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ line-height: 1;
+ background: transparent;
+ border: none;
+ border-radius: 50%;
+ cursor: pointer;
+ transition: background-color 150ms ease;
+
+ svg {
+ path {
+ fill: $ba-BoundingBoxHighlightNav-arrow;
+ }
+ }
+
+ &:hover:not(:disabled) {
+ background: rgba(0, 0, 0, .06);
+ }
+
+ &:active:not(:disabled) {
+ background: rgba(0, 0, 0, .12);
+ }
+
+ &:disabled {
+ cursor: default;
+
+ svg {
+ path {
+ fill: $ba-BoundingBoxHighlightNav-border;
+ }
+ }
+ }
+}
+
+.ba-BoundingBoxHighlightNav-counter {
+ padding: 0 4px;
+ color: $ba-BoundingBoxHighlightNav-counter;
+ font-weight: normal;
+ font-size: 15px;
+ font-family: Lato, Arial, sans-serif;
+ user-select: none;
+}
diff --git a/src/components/BoundingBoxHighlight/BoundingBoxHighlightNav.tsx b/src/components/BoundingBoxHighlight/BoundingBoxHighlightNav.tsx
new file mode 100644
index 000000000..1c33dfc30
--- /dev/null
+++ b/src/components/BoundingBoxHighlight/BoundingBoxHighlightNav.tsx
@@ -0,0 +1,49 @@
+import * as React from 'react';
+import { useIntl } from 'react-intl';
+import ChevronLeft from '../../icons/ChevronLeft';
+import ChevronRight from '../../icons/ChevronRight';
+import messages from './messages';
+import './BoundingBoxHighlightNav.scss';
+
+type Props = {
+ currentIndex: number;
+ total: number;
+ onPrev: (event: React.MouseEvent) => void;
+ onNext: (event: React.MouseEvent) => void;
+};
+
+const BoundingBoxHighlightNav = ({ currentIndex, total, onPrev, onNext }: Props): JSX.Element => {
+ const intl = useIntl();
+ const isPrevDisabled = currentIndex === 0;
+ const isNextDisabled = currentIndex === total - 1;
+
+ return (
+
+
+
+ {currentIndex + 1} / {total}
+
+
+
+ );
+};
+
+export default BoundingBoxHighlightNav;
diff --git a/src/components/BoundingBoxHighlight/BoundingBoxHighlightRect.scss b/src/components/BoundingBoxHighlight/BoundingBoxHighlightRect.scss
new file mode 100644
index 000000000..bcd3ffdad
--- /dev/null
+++ b/src/components/BoundingBoxHighlight/BoundingBoxHighlightRect.scss
@@ -0,0 +1,22 @@
+$ba-BoundingBoxHighlight-color: #0061d5;
+$ba-BoundingBoxHighlight-bgColor--default: rgba($ba-BoundingBoxHighlight-color, .05);
+$ba-BoundingBoxHighlight-bgColor--hover: rgba($ba-BoundingBoxHighlight-color, .16);
+$ba-BoundingBoxHighlight-bgColor--selected: rgba($ba-BoundingBoxHighlight-color, .1);
+
+.ba-BoundingBoxHighlightRect {
+ position: absolute;
+ box-sizing: border-box;
+ background-color: $ba-BoundingBoxHighlight-bgColor--default;
+ border: 2px solid rgba($ba-BoundingBoxHighlight-color, .2);
+ border-radius: 8px;
+ outline: none;
+ cursor: pointer;
+ transition: background-color 200ms ease, border-color 200ms ease, box-shadow 200ms ease;
+ pointer-events: auto;
+
+ &.is-selected {
+ background-color: $ba-BoundingBoxHighlight-bgColor--selected;
+ border-color: $ba-BoundingBoxHighlight-color;
+ box-shadow: 0 0 0 2px rgba($ba-BoundingBoxHighlight-color, .3), 0 4px 12px rgba(0, 0, 0, .15);
+ }
+}
diff --git a/src/components/BoundingBoxHighlight/BoundingBoxHighlightRect.tsx b/src/components/BoundingBoxHighlight/BoundingBoxHighlightRect.tsx
new file mode 100644
index 000000000..b438a66a8
--- /dev/null
+++ b/src/components/BoundingBoxHighlight/BoundingBoxHighlightRect.tsx
@@ -0,0 +1,77 @@
+import * as React from 'react';
+import classNames from 'classnames';
+import BoundingBoxHighlightNav from './BoundingBoxHighlightNav';
+import { BoundingBox } from './types';
+import './BoundingBoxHighlightRect.scss';
+
+type Props = {
+ boundingBox: BoundingBox;
+ currentIndex: number;
+ isSelected?: boolean;
+ onNavigate?: (id: string) => void;
+ onSelect?: (id: string) => void;
+ total: number;
+ prevId?: string;
+ nextId?: string;
+};
+
+const BoundingBoxHighlightRect = ({
+ boundingBox,
+ currentIndex,
+ isSelected,
+ onNavigate,
+ onSelect,
+ total,
+ prevId,
+ nextId,
+}: Props): JSX.Element => {
+ const { id, x, y, width, height } = boundingBox;
+
+ const style: React.CSSProperties = {
+ display: 'block',
+ left: `${x}%`,
+ top: `${y}%`,
+ width: `${width}%`,
+ height: `${height}%`,
+ };
+
+ const handleClick = (event: React.MouseEvent): void => {
+ event.stopPropagation();
+ onSelect?.(id);
+ };
+
+ const handlePrev = (event: React.MouseEvent): void => {
+ event.stopPropagation();
+ if (prevId) {
+ onNavigate?.(prevId);
+ }
+ };
+
+ const handleNext = (event: React.MouseEvent): void => {
+ event.stopPropagation();
+ if (nextId) {
+ onNavigate?.(nextId);
+ }
+ };
+
+ return (
+
+ {isSelected && total > 1 && (
+
+ )}
+
+ );
+};
+
+export default BoundingBoxHighlightRect;
diff --git a/src/components/BoundingBoxHighlight/__tests__/BoundingBoxHighlightList-test.tsx b/src/components/BoundingBoxHighlight/__tests__/BoundingBoxHighlightList-test.tsx
new file mode 100644
index 000000000..c85e9d2c5
--- /dev/null
+++ b/src/components/BoundingBoxHighlight/__tests__/BoundingBoxHighlightList-test.tsx
@@ -0,0 +1,187 @@
+import React from 'react';
+import { fireEvent, render, screen, type RenderResult } from '@testing-library/react';
+import BoundingBoxHighlightList from '../BoundingBoxHighlightList';
+import { BoundingBox } from '../types';
+
+describe('BoundingBoxHighlightList', () => {
+ const createBoundingBox = (id: string, pageNumber = 1): BoundingBox => ({
+ id,
+ x: 10,
+ y: 20,
+ width: 30,
+ height: 40,
+ pageNumber,
+ });
+
+ const allBoundingBoxes: BoundingBox[] = [
+ createBoundingBox('box-1'),
+ createBoundingBox('box-2'),
+ createBoundingBox('box-3'),
+ ];
+
+ const defaults = {
+ allBoundingBoxes,
+ boundingBoxes: allBoundingBoxes,
+ selectedId: 'box-2' as string | null,
+ };
+
+ const renderList = (props = {}): RenderResult =>
+ render();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('render()', () => {
+ test('should render container with correct data-testid', () => {
+ renderList();
+
+ expect(screen.getByTestId('ba-BoundingBoxHighlightList')).toBeInTheDocument();
+ });
+
+ test('should render BoundingBoxHighlightRect for each boundingBox', () => {
+ const boundingBoxes = [createBoundingBox('box-a'), createBoundingBox('box-b')];
+ renderList({
+ allBoundingBoxes: boundingBoxes,
+ boundingBoxes,
+ selectedId: 'box-a',
+ });
+
+ expect(screen.getByTestId('ba-BoundingBoxHighlightRect-box-a')).toBeInTheDocument();
+ expect(screen.getByTestId('ba-BoundingBoxHighlightRect-box-b')).toBeInTheDocument();
+ });
+
+ test('should pass boundingBox to each BoundingBoxHighlightRect', () => {
+ renderList();
+
+ expect(screen.getByTestId('ba-BoundingBoxHighlightRect-box-1')).toBeInTheDocument();
+ expect(screen.getByTestId('ba-BoundingBoxHighlightRect-box-2')).toBeInTheDocument();
+ expect(screen.getByTestId('ba-BoundingBoxHighlightRect-box-3')).toBeInTheDocument();
+ });
+
+ test('should pass total from allBoundingBoxes length', () => {
+ renderList();
+
+ // Selected rect shows nav with "X / total" - box-2 is selected, index 1, so "2 / 3"
+ expect(screen.getByText('2 / 3')).toBeInTheDocument();
+ });
+
+ test('should pass isSelected true only for the selected boundingBox', () => {
+ renderList({ selectedId: 'box-2' });
+
+ const selectedRect = screen.getByTestId('ba-BoundingBoxHighlightRect-box-2');
+ expect(selectedRect).toHaveClass('is-selected');
+ expect(screen.getByTestId('ba-BoundingBoxHighlightRect-box-1')).not.toHaveClass(
+ 'is-selected',
+ );
+ expect(screen.getByTestId('ba-BoundingBoxHighlightRect-box-3')).not.toHaveClass(
+ 'is-selected',
+ );
+ });
+
+ test('should pass currentIndex based on selectedId position in allBoundingBoxes', () => {
+ renderList({ selectedId: 'box-3' });
+
+ // box-3 is at index 2, so counter shows "3 / 3"
+ expect(screen.getByText('3 / 3')).toBeInTheDocument();
+ });
+
+ test('should pass onNavigate and onSelect callbacks', () => {
+ const onNavigate = jest.fn();
+ const onSelect = jest.fn();
+ renderList({ onNavigate, onSelect });
+
+ fireEvent.click(screen.getByTestId('ba-BoundingBoxHighlightRect-box-1'));
+ expect(onSelect).toHaveBeenCalledWith('box-1');
+
+ // Click prev on selected box (box-2) to navigate
+ fireEvent.click(screen.getByTestId('ba-BoundingBoxHighlightNav-prev'));
+ expect(onNavigate).toHaveBeenCalledWith('box-1');
+ });
+ });
+
+ describe('prevId and nextId', () => {
+ test('should pass prevId and nextId for circular navigation when total > 1', () => {
+ const onNavigate = jest.fn();
+ renderList({ selectedId: 'box-2', onNavigate });
+
+ fireEvent.click(screen.getByTestId('ba-BoundingBoxHighlightNav-prev'));
+ expect(onNavigate).toHaveBeenCalledWith('box-1');
+ fireEvent.click(screen.getByTestId('ba-BoundingBoxHighlightNav-next'));
+ expect(onNavigate).toHaveBeenCalledWith('box-3');
+ });
+
+ test('should not render nav when total is 1', () => {
+ const singleHighlight = [createBoundingBox('box-only')];
+ renderList({
+ allBoundingBoxes: singleHighlight,
+ boundingBoxes: singleHighlight,
+ selectedId: 'box-only',
+ });
+
+ // Nav is not shown when total is 1 (nothing to navigate to)
+ expect(screen.queryByTestId('ba-BoundingBoxHighlightNav')).not.toBeInTheDocument();
+ });
+
+ test('should disable prev button when selectedId is at first item', () => {
+ renderList({ selectedId: 'box-1' });
+
+ expect(screen.getByTestId('ba-BoundingBoxHighlightNav-prev')).toBeDisabled();
+ });
+
+ test('should disable next button when selectedId is at last item', () => {
+ renderList({ selectedId: 'box-3' });
+
+ expect(screen.getByTestId('ba-BoundingBoxHighlightNav-next')).toBeDisabled();
+ });
+
+ test('should render without nav when selectedId is not in allBoundingBoxes', () => {
+ renderList({ selectedId: 'non-existent' });
+
+ // No rect is selected, so nav is not rendered
+ expect(screen.queryByTestId('ba-BoundingBoxHighlightNav')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('boundingBoxes subset of allBoundingBoxes', () => {
+ test('should render only boundingBoxes but use allBoundingBoxes for navigation indices', () => {
+ const page2Boxes = [createBoundingBox('box-2', 2), createBoundingBox('box-3', 2)];
+ const onNavigate = jest.fn();
+ renderList({
+ boundingBoxes: page2Boxes,
+ selectedId: 'box-2',
+ onNavigate,
+ });
+
+ expect(screen.getByTestId('ba-BoundingBoxHighlightRect-box-2')).toBeInTheDocument();
+ expect(screen.getByTestId('ba-BoundingBoxHighlightRect-box-3')).toBeInTheDocument();
+ expect(screen.queryByTestId('ba-BoundingBoxHighlightRect-box-1')).not.toBeInTheDocument();
+
+ // Counter shows position in allBoundingBoxes (box-2 is index 1, so "2 / 3")
+ expect(screen.getByText('2 / 3')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByTestId('ba-BoundingBoxHighlightNav-prev'));
+ expect(onNavigate).toHaveBeenCalledWith('box-1');
+ fireEvent.click(screen.getByTestId('ba-BoundingBoxHighlightNav-next'));
+ expect(onNavigate).toHaveBeenCalledWith('box-3');
+ });
+ });
+
+ describe('empty state', () => {
+ test('should return null when allBoundingBoxes is empty', () => {
+ const { container } = renderList({
+ allBoundingBoxes: [],
+ boundingBoxes: [],
+ });
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ test('should render empty list when boundingBoxes is empty but allBoundingBoxes has items', () => {
+ renderList({ boundingBoxes: [] });
+
+ expect(screen.queryByTestId('ba-BoundingBoxHighlightRect-box-1')).not.toBeInTheDocument();
+ expect(screen.getByTestId('ba-BoundingBoxHighlightList')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/components/BoundingBoxHighlight/__tests__/BoundingBoxHighlightNav-test.tsx b/src/components/BoundingBoxHighlight/__tests__/BoundingBoxHighlightNav-test.tsx
new file mode 100644
index 000000000..cebee9ce5
--- /dev/null
+++ b/src/components/BoundingBoxHighlight/__tests__/BoundingBoxHighlightNav-test.tsx
@@ -0,0 +1,138 @@
+import React from 'react';
+import { fireEvent, render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import BoundingBoxHighlightNav from '../BoundingBoxHighlightNav';
+
+describe('BoundingBoxHighlightNav', () => {
+ const defaults = {
+ currentIndex: 1,
+ total: 5,
+ onPrev: jest.fn(),
+ onNext: jest.fn(),
+ };
+
+ const renderNav = (props = {}): ReturnType =>
+ render();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('render()', () => {
+ test('should render the nav container with correct data-testid', () => {
+ renderNav();
+
+ expect(screen.getByTestId('ba-BoundingBoxHighlightNav')).toBeInTheDocument();
+ });
+
+ test('should render prev and next buttons', () => {
+ renderNav();
+
+ expect(screen.getByTestId('ba-BoundingBoxHighlightNav-prev')).toBeInTheDocument();
+ expect(screen.getByTestId('ba-BoundingBoxHighlightNav-next')).toBeInTheDocument();
+ });
+
+ test('should display the counter with current position and total', () => {
+ renderNav({ currentIndex: 2, total: 5 });
+
+ expect(screen.getByText('3 / 5')).toBeInTheDocument();
+ });
+
+ test('should display 1-based index in the counter', () => {
+ renderNav({ currentIndex: 0, total: 3 });
+
+ expect(screen.getByText('1 / 3')).toBeInTheDocument();
+ });
+ });
+
+ describe('button states', () => {
+ test('should disable prev button when currentIndex is 0', () => {
+ renderNav({ currentIndex: 0, total: 5 });
+
+ expect(screen.getByTestId('ba-BoundingBoxHighlightNav-prev')).toBeDisabled();
+ });
+
+ test('should enable prev button when currentIndex is greater than 0', () => {
+ renderNav({ currentIndex: 1, total: 5 });
+
+ expect(screen.getByTestId('ba-BoundingBoxHighlightNav-prev')).not.toBeDisabled();
+ });
+
+ test('should disable next button when currentIndex is at last item', () => {
+ renderNav({ currentIndex: 4, total: 5 });
+
+ expect(screen.getByTestId('ba-BoundingBoxHighlightNav-next')).toBeDisabled();
+ });
+
+ test('should enable next button when currentIndex is not at last item', () => {
+ renderNav({ currentIndex: 2, total: 5 });
+
+ expect(screen.getByTestId('ba-BoundingBoxHighlightNav-next')).not.toBeDisabled();
+ });
+
+ test('should disable both buttons when total is 1', () => {
+ renderNav({ currentIndex: 0, total: 1 });
+
+ expect(screen.getByTestId('ba-BoundingBoxHighlightNav-prev')).toBeDisabled();
+ expect(screen.getByTestId('ba-BoundingBoxHighlightNav-next')).toBeDisabled();
+ });
+ });
+
+ describe('callbacks', () => {
+ test('should call onPrev when prev button is clicked', () => {
+ const onPrev = jest.fn();
+ renderNav({ currentIndex: 1, onPrev });
+
+ fireEvent.click(screen.getByTestId('ba-BoundingBoxHighlightNav-prev'));
+
+ expect(onPrev).toHaveBeenCalledTimes(1);
+ });
+
+ test('should call onNext when next button is clicked', () => {
+ const onNext = jest.fn();
+ renderNav({ currentIndex: 1, onNext });
+
+ fireEvent.click(screen.getByTestId('ba-BoundingBoxHighlightNav-next'));
+
+ expect(onNext).toHaveBeenCalledTimes(1);
+ });
+
+ test('should not call onPrev when prev button is disabled and clicked', () => {
+ const onPrev = jest.fn();
+ renderNav({ currentIndex: 0, onPrev });
+
+ fireEvent.click(screen.getByTestId('ba-BoundingBoxHighlightNav-prev'));
+
+ expect(onPrev).not.toHaveBeenCalled();
+ });
+
+ test('should not call onNext when next button is disabled and clicked', () => {
+ const onNext = jest.fn();
+ renderNav({ currentIndex: 4, total: 5, onNext });
+
+ fireEvent.click(screen.getByTestId('ba-BoundingBoxHighlightNav-next'));
+
+ expect(onNext).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('accessibility', () => {
+ test('should have aria-label on prev button', () => {
+ renderNav();
+
+ expect(screen.getByTestId('ba-BoundingBoxHighlightNav-prev')).toHaveAttribute(
+ 'aria-label',
+ 'View previous reference',
+ );
+ });
+
+ test('should have aria-label on next button', () => {
+ renderNav();
+
+ expect(screen.getByTestId('ba-BoundingBoxHighlightNav-next')).toHaveAttribute(
+ 'aria-label',
+ 'View next reference',
+ );
+ });
+ });
+});
diff --git a/src/components/BoundingBoxHighlight/__tests__/BoundingBoxHighlightRect-test.tsx b/src/components/BoundingBoxHighlight/__tests__/BoundingBoxHighlightRect-test.tsx
new file mode 100644
index 000000000..dfcfa7968
--- /dev/null
+++ b/src/components/BoundingBoxHighlight/__tests__/BoundingBoxHighlightRect-test.tsx
@@ -0,0 +1,219 @@
+import React from 'react';
+import { fireEvent, render, screen, type RenderResult } from '@testing-library/react';
+import BoundingBoxHighlightRect from '../BoundingBoxHighlightRect';
+import { BoundingBox } from '../types';
+
+describe('BoundingBoxHighlightRect', () => {
+ const defaultBoundingBox: BoundingBox = {
+ id: 'box-1',
+ x: 10,
+ y: 20,
+ width: 30,
+ height: 40,
+ pageNumber: 1,
+ };
+
+ const defaults = {
+ boundingBox: defaultBoundingBox,
+ currentIndex: 1,
+ total: 5,
+ };
+
+ const renderRect = (props = {}): RenderResult =>
+ render();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('render()', () => {
+ test('should render the rect with correct data-testid', () => {
+ renderRect();
+
+ expect(screen.getByTestId('ba-BoundingBoxHighlightRect-box-1')).toBeInTheDocument();
+ });
+
+ test('should apply styles from boundingBox dimensions', () => {
+ renderRect();
+
+ const rect = screen.getByTestId('ba-BoundingBoxHighlightRect-box-1');
+ expect(rect).toHaveStyle({
+ display: 'block',
+ left: '10%',
+ top: '20%',
+ width: '30%',
+ height: '40%',
+ });
+ });
+
+ test('should apply is-selected class when isSelected is true', () => {
+ renderRect({ isSelected: true });
+
+ const rect = screen.getByTestId('ba-BoundingBoxHighlightRect-box-1');
+ expect(rect).toHaveClass('is-selected');
+ });
+
+ test('should not apply is-selected class when isSelected is false', () => {
+ renderRect({ isSelected: false });
+
+ const rect = screen.getByTestId('ba-BoundingBoxHighlightRect-box-1');
+ expect(rect).not.toHaveClass('is-selected');
+ });
+
+ test('should not render BoundingBoxHighlightNav when not selected', () => {
+ renderRect({ isSelected: false });
+
+ expect(screen.queryByTestId('ba-BoundingBoxHighlightNav')).not.toBeInTheDocument();
+ });
+
+ test('should not render BoundingBoxHighlightNav when total is 0', () => {
+ renderRect({ isSelected: true, total: 0 });
+
+ expect(screen.queryByTestId('ba-BoundingBoxHighlightNav')).not.toBeInTheDocument();
+ });
+
+ test('should not render BoundingBoxHighlightNav when selected and total is 1', () => {
+ renderRect({ isSelected: true, total: 1 });
+
+ expect(screen.queryByTestId('ba-BoundingBoxHighlightNav')).not.toBeInTheDocument();
+ });
+
+ test('should render BoundingBoxHighlightNav when selected and total > 1', () => {
+ renderRect({ isSelected: true, total: 2 });
+
+ expect(screen.getByTestId('ba-BoundingBoxHighlightNav')).toBeInTheDocument();
+ });
+
+ test('should pass currentIndex and total to BoundingBoxHighlightNav', () => {
+ renderRect({
+ isSelected: true,
+ currentIndex: 2,
+ total: 5,
+ });
+
+ expect(screen.getByText('3 / 5')).toBeInTheDocument();
+ });
+ });
+
+ describe('onSelect', () => {
+ test('should call onSelect with boundingBox id when rect is clicked', () => {
+ const onSelect = jest.fn();
+ renderRect({ onSelect });
+
+ fireEvent.click(screen.getByTestId('ba-BoundingBoxHighlightRect-box-1'));
+
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(onSelect).toHaveBeenCalledWith('box-1');
+ });
+
+ test('should not throw when onSelect is not provided', () => {
+ renderRect();
+
+ expect(() => {
+ fireEvent.click(screen.getByTestId('ba-BoundingBoxHighlightRect-box-1'));
+ }).not.toThrow();
+ });
+
+ test('should stop propagation on click', () => {
+ const onSelect = jest.fn();
+ renderRect({ onSelect });
+ const event = new MouseEvent('click', { bubbles: true });
+ const stopPropagationSpy = jest.spyOn(event, 'stopPropagation');
+
+ fireEvent(screen.getByTestId('ba-BoundingBoxHighlightRect-box-1'), event);
+
+ expect(stopPropagationSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe('navigation', () => {
+ test('should call onNavigate with prevId when prev button is clicked', () => {
+ const onNavigate = jest.fn();
+ renderRect({
+ isSelected: true,
+ prevId: 'box-0',
+ nextId: 'box-2',
+ onNavigate,
+ });
+
+ fireEvent.click(screen.getByTestId('ba-BoundingBoxHighlightNav-prev'));
+
+ expect(onNavigate).toHaveBeenCalledTimes(1);
+ expect(onNavigate).toHaveBeenCalledWith('box-0');
+ });
+
+ test('should call onNavigate with nextId when next button is clicked', () => {
+ const onNavigate = jest.fn();
+ renderRect({
+ isSelected: true,
+ prevId: 'box-0',
+ nextId: 'box-2',
+ onNavigate,
+ });
+
+ fireEvent.click(screen.getByTestId('ba-BoundingBoxHighlightNav-next'));
+
+ expect(onNavigate).toHaveBeenCalledTimes(1);
+ expect(onNavigate).toHaveBeenCalledWith('box-2');
+ });
+
+ test('should not call onNavigate when prevId is undefined and prev button is clicked', () => {
+ const onNavigate = jest.fn();
+ renderRect({
+ isSelected: true,
+ prevId: undefined,
+ nextId: 'box-2',
+ onNavigate,
+ currentIndex: 1,
+ });
+
+ fireEvent.click(screen.getByTestId('ba-BoundingBoxHighlightNav-prev'));
+
+ expect(onNavigate).not.toHaveBeenCalled();
+ });
+
+ test('should not call onNavigate when nextId is undefined and next button is clicked', () => {
+ const onNavigate = jest.fn();
+ renderRect({
+ isSelected: true,
+ prevId: 'box-0',
+ nextId: undefined,
+ onNavigate,
+ currentIndex: 2,
+ total: 5,
+ });
+
+ fireEvent.click(screen.getByTestId('ba-BoundingBoxHighlightNav-next'));
+
+ expect(onNavigate).not.toHaveBeenCalled();
+ });
+
+ test('should stop propagation when nav buttons are clicked', () => {
+ const onNavigate = jest.fn();
+ renderRect({
+ isSelected: true,
+ prevId: 'box-0',
+ nextId: 'box-2',
+ onNavigate,
+ });
+
+ const prevEvent = new MouseEvent('click', { bubbles: true });
+ const prevStopSpy = jest.spyOn(prevEvent, 'stopPropagation');
+ fireEvent(screen.getByTestId('ba-BoundingBoxHighlightNav-prev'), prevEvent);
+ expect(prevStopSpy).toHaveBeenCalled();
+
+ const nextEvent = new MouseEvent('click', { bubbles: true });
+ const nextStopSpy = jest.spyOn(nextEvent, 'stopPropagation');
+ fireEvent(screen.getByTestId('ba-BoundingBoxHighlightNav-next'), nextEvent);
+ expect(nextStopSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe('accessibility', () => {
+ test('should have role="presentation" on the rect', () => {
+ renderRect();
+
+ expect(screen.getByTestId('ba-BoundingBoxHighlightRect-box-1')).toHaveAttribute('role', 'presentation');
+ });
+ });
+});
diff --git a/src/components/BoundingBoxHighlight/index.ts b/src/components/BoundingBoxHighlight/index.ts
new file mode 100644
index 000000000..dc76c5c5d
--- /dev/null
+++ b/src/components/BoundingBoxHighlight/index.ts
@@ -0,0 +1,4 @@
+export { default as BoundingBoxHighlightList } from './BoundingBoxHighlightList';
+export { default as BoundingBoxHighlightNav } from './BoundingBoxHighlightNav';
+export { default as BoundingBoxHighlightRect } from './BoundingBoxHighlightRect';
+export type { BoundingBox } from './types';
diff --git a/src/components/BoundingBoxHighlight/messages.ts b/src/components/BoundingBoxHighlight/messages.ts
new file mode 100644
index 000000000..f21c3acd1
--- /dev/null
+++ b/src/components/BoundingBoxHighlight/messages.ts
@@ -0,0 +1,14 @@
+import { defineMessages } from 'react-intl';
+
+export default defineMessages({
+ ariaLabelViewNextReference: {
+ id: 'ba.boundingBoxHighlight.viewNextReference',
+ description: 'Aria label for the button to view the next reference',
+ defaultMessage: 'View next reference',
+ },
+ ariaLabelViewPrevReference: {
+ id: 'ba.boundingBoxHighlight.viewPrevReference',
+ description: 'Aria label for the button to view the previous reference',
+ defaultMessage: 'View previous reference',
+ },
+});
diff --git a/src/components/BoundingBoxHighlight/types.ts b/src/components/BoundingBoxHighlight/types.ts
new file mode 100644
index 000000000..02ab9d1b9
--- /dev/null
+++ b/src/components/BoundingBoxHighlight/types.ts
@@ -0,0 +1,8 @@
+export interface BoundingBox {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ pageNumber: number;
+ id: string;
+}
diff --git a/src/icons/ChevronLeft.tsx b/src/icons/ChevronLeft.tsx
new file mode 100644
index 000000000..04b5b940e
--- /dev/null
+++ b/src/icons/ChevronLeft.tsx
@@ -0,0 +1,24 @@
+import * as React from 'react';
+
+function ChevronLeft(props: React.SVGProps): JSX.Element {
+ return (
+
+ );
+}
+
+export default ChevronLeft;
diff --git a/src/icons/ChevronRight.tsx b/src/icons/ChevronRight.tsx
new file mode 100644
index 000000000..d48507365
--- /dev/null
+++ b/src/icons/ChevronRight.tsx
@@ -0,0 +1,24 @@
+import * as React from 'react';
+
+function ChevronRight(props: React.SVGProps): JSX.Element {
+ return (
+
+ );
+}
+
+export default ChevronRight;