From c87d7ead7569f4f4d1d5759de4186c4716239117 Mon Sep 17 00:00:00 2001 From: Damian Lasecki Date: Mon, 16 Mar 2026 19:50:34 +0100 Subject: [PATCH 1/3] feat: add bounding box highlight UI components and types - Add BoundingBoxHighlightRect, BoundingBoxHighlightNav, BoundingBoxHighlightList - Add BoundingBox type and component styles - Add ChevronLeft and ChevronRight icons - Add messages for aria labels - Add comprehensive unit tests for all components Made-with: Cursor feat: update tests feat: update tests feat: move bboxes under components feat: rename directory --- .../BoundingBoxHighlightList.scss | 8 + .../BoundingBoxHighlightList.tsx | 63 ++++++ .../BoundingBoxHighlightNav.scss | 73 ++++++ .../BoundingBoxHighlightNav.tsx | 49 ++++ .../BoundingBoxHighlightRect.scss | 22 ++ .../BoundingBoxHighlightRect.tsx | 77 +++++++ .../BoundingBoxHighlightList-test.tsx | 179 +++++++++++++++ .../BoundingBoxHighlightNav-test.tsx | 138 ++++++++++++ .../BoundingBoxHighlightRect-test.tsx | 213 ++++++++++++++++++ src/components/BoundingBoxHighlight/index.ts | 4 + .../BoundingBoxHighlight/messages.ts | 14 ++ src/components/BoundingBoxHighlight/types.ts | 8 + src/icons/ChevronLeft.tsx | 24 ++ src/icons/ChevronRight.tsx | 24 ++ 14 files changed, 896 insertions(+) create mode 100644 src/components/BoundingBoxHighlight/BoundingBoxHighlightList.scss create mode 100644 src/components/BoundingBoxHighlight/BoundingBoxHighlightList.tsx create mode 100644 src/components/BoundingBoxHighlight/BoundingBoxHighlightNav.scss create mode 100644 src/components/BoundingBoxHighlight/BoundingBoxHighlightNav.tsx create mode 100644 src/components/BoundingBoxHighlight/BoundingBoxHighlightRect.scss create mode 100644 src/components/BoundingBoxHighlight/BoundingBoxHighlightRect.tsx create mode 100644 src/components/BoundingBoxHighlight/__tests__/BoundingBoxHighlightList-test.tsx create mode 100644 src/components/BoundingBoxHighlight/__tests__/BoundingBoxHighlightNav-test.tsx create mode 100644 src/components/BoundingBoxHighlight/__tests__/BoundingBoxHighlightRect-test.tsx create mode 100644 src/components/BoundingBoxHighlight/index.ts create mode 100644 src/components/BoundingBoxHighlight/messages.ts create mode 100644 src/components/BoundingBoxHighlight/types.ts create mode 100644 src/icons/ChevronLeft.tsx create mode 100644 src/icons/ChevronRight.tsx 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..3f6af74a4 --- /dev/null +++ b/src/components/BoundingBoxHighlight/BoundingBoxHighlightList.tsx @@ -0,0 +1,63 @@ +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): JSX.Element => { + const selectedIndex = allBoundingBoxes.findIndex(h => h.id === selectedId); + const total = allBoundingBoxes.length; + + return ( +
+ {boundingBoxes.map(bbox => { + const prevId = total > 1 ? allBoundingBoxes[(selectedIndex - 1 + total) % total].id : undefined; + const nextId = total > 1 ? allBoundingBoxes[(selectedIndex + 1) % total].id : undefined; + + 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..5bbcb7386 --- /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 > 0 && ( + + )} +
+ ); +}; + +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..bb2035106 --- /dev/null +++ b/src/components/BoundingBoxHighlight/__tests__/BoundingBoxHighlightList-test.tsx @@ -0,0 +1,179 @@ +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 pass undefined prevId and nextId when total is 1', () => { + const singleHighlight = [createBoundingBox('box-only')]; + renderList({ + allBoundingBoxes: singleHighlight, + boundingBoxes: singleHighlight, + selectedId: 'box-only', + }); + + // Nav is shown when selected and total > 0, but prev/next buttons are disabled when total is 1 + expect(screen.getByTestId('ba-BoundingBoxHighlightNav-prev')).toBeDisabled(); + expect(screen.getByTestId('ba-BoundingBoxHighlightNav-next')).toBeDisabled(); + }); + + 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 render empty list when boundingBoxes is empty', () => { + 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..35a0f4f98 --- /dev/null +++ b/src/components/BoundingBoxHighlight/__tests__/BoundingBoxHighlightRect-test.tsx @@ -0,0 +1,213 @@ +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 render BoundingBoxHighlightNav when selected and total > 0', () => { + renderRect({ isSelected: true }); + + 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; From b9bf3b04cd566422331e37173c75a7436739acbe Mon Sep 17 00:00:00 2001 From: Damian Lasecki Date: Tue, 17 Mar 2026 15:32:08 +0100 Subject: [PATCH 2/3] feat: address code review comments --- .../BoundingBoxHighlightList.tsx | 14 +++++++++----- .../BoundingBoxHighlightRect.tsx | 2 +- .../BoundingBoxHighlightList-test.tsx | 18 +++++++++++++----- .../BoundingBoxHighlightRect-test.tsx | 10 ++++++++-- 4 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/components/BoundingBoxHighlight/BoundingBoxHighlightList.tsx b/src/components/BoundingBoxHighlight/BoundingBoxHighlightList.tsx index 3f6af74a4..54bde48d5 100644 --- a/src/components/BoundingBoxHighlight/BoundingBoxHighlightList.tsx +++ b/src/components/BoundingBoxHighlight/BoundingBoxHighlightList.tsx @@ -32,16 +32,20 @@ const BoundingBoxHighlightList = ({ onNavigate, onSelect, selectedId, -}: Props): JSX.Element => { - const selectedIndex = allBoundingBoxes.findIndex(h => h.id === selectedId); +}: Props): React.ReactNode => { 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 => { - const prevId = total > 1 ? allBoundingBoxes[(selectedIndex - 1 + total) % total].id : undefined; - const nextId = total > 1 ? allBoundingBoxes[(selectedIndex + 1) % total].id : undefined; - return ( - {isSelected && total > 0 && ( + {isSelected && total > 1 && ( { expect(onNavigate).toHaveBeenCalledWith('box-3'); }); - test('should pass undefined prevId and nextId when total is 1', () => { + test('should not render nav when total is 1', () => { const singleHighlight = [createBoundingBox('box-only')]; renderList({ allBoundingBoxes: singleHighlight, @@ -119,9 +119,8 @@ describe('BoundingBoxHighlightList', () => { selectedId: 'box-only', }); - // Nav is shown when selected and total > 0, but prev/next buttons are disabled when total is 1 - expect(screen.getByTestId('ba-BoundingBoxHighlightNav-prev')).toBeDisabled(); - expect(screen.getByTestId('ba-BoundingBoxHighlightNav-next')).toBeDisabled(); + // 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', () => { @@ -169,7 +168,16 @@ describe('BoundingBoxHighlightList', () => { }); describe('empty state', () => { - test('should render empty list when boundingBoxes is empty', () => { + 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(); diff --git a/src/components/BoundingBoxHighlight/__tests__/BoundingBoxHighlightRect-test.tsx b/src/components/BoundingBoxHighlight/__tests__/BoundingBoxHighlightRect-test.tsx index 35a0f4f98..dfcfa7968 100644 --- a/src/components/BoundingBoxHighlight/__tests__/BoundingBoxHighlightRect-test.tsx +++ b/src/components/BoundingBoxHighlight/__tests__/BoundingBoxHighlightRect-test.tsx @@ -72,8 +72,14 @@ describe('BoundingBoxHighlightRect', () => { expect(screen.queryByTestId('ba-BoundingBoxHighlightNav')).not.toBeInTheDocument(); }); - test('should render BoundingBoxHighlightNav when selected and total > 0', () => { - renderRect({ isSelected: true }); + 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(); }); From 8b2f56188cccc2fd81bc50172c750c32594ea0fb Mon Sep 17 00:00:00 2001 From: Damian Lasecki Date: Tue, 17 Mar 2026 15:33:27 +0100 Subject: [PATCH 3/3] feat: address code review comments --- .../BoundingBoxHighlight/BoundingBoxHighlightList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/BoundingBoxHighlight/BoundingBoxHighlightList.tsx b/src/components/BoundingBoxHighlight/BoundingBoxHighlightList.tsx index 54bde48d5..b45a98fec 100644 --- a/src/components/BoundingBoxHighlight/BoundingBoxHighlightList.tsx +++ b/src/components/BoundingBoxHighlight/BoundingBoxHighlightList.tsx @@ -32,7 +32,7 @@ const BoundingBoxHighlightList = ({ onNavigate, onSelect, selectedId, -}: Props): React.ReactNode => { +}: Props): React.ReactElement | null => { const total = allBoundingBoxes.length; if (total === 0) {