From d028b7ac1324a299ed1e4a043ceedd2966cb9064 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Mon, 11 May 2026 18:31:12 -0500 Subject: [PATCH 1/3] feat(ui): add BibleChapterPicker.Content onSelect + BibleReader onChapterPickerPress context threading - Add BibleChapterPickerSelectData type and onSelect callback to Content - Content fires onSelect after state updates, before onRequestClose - Thread onChapterPickerPress through BibleReader.Root context to Toolbar - Export BibleChapterPickerSelectData from barrel - Tests: onSelect normal/intro chapters, default popover preserved, reader toolbar integration --- .../components/bible-chapter-picker.test.tsx | 85 ++++++++++++++++++- .../src/components/bible-chapter-picker.tsx | 10 ++- .../ui/src/components/bible-reader.test.tsx | 35 ++++++++ packages/ui/src/components/bible-reader.tsx | 8 +- packages/ui/src/components/index.ts | 1 + 5 files changed, 136 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/bible-chapter-picker.test.tsx b/packages/ui/src/components/bible-chapter-picker.test.tsx index 6fdac5ac..bdae22c7 100644 --- a/packages/ui/src/components/bible-chapter-picker.test.tsx +++ b/packages/ui/src/components/bible-chapter-picker.test.tsx @@ -4,7 +4,7 @@ // We stub ResizeObserver for jsdom (used by Radix/@floating-ui). The stub methods are intentionally no-ops. /* eslint-disable @typescript-eslint/no-empty-function */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; // ResizeObserver is used by @floating-ui/dom (Radix Popover) @@ -16,6 +16,7 @@ class ResizeObserverMock { globalThis.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; import { BibleChapterPicker } from './bible-chapter-picker'; +import type { BibleChapterPickerSelectData } from './bible-chapter-picker'; import { useBooks, useTheme } from '@youversion/platform-react-hooks'; import type { BibleBook } from '@youversion/platform-core'; @@ -115,3 +116,85 @@ describe('BibleChapterPicker - default popover mode', () => { expect(await screen.findByPlaceholderText('Search')).toBeInTheDocument(); }); }); + +describe('BibleChapterPicker.Content onSelect', () => { + beforeEach(() => { + vi.clearAllMocks(); + setupDefaultMocks(); + }); + + it('calls onSelect with { book, chapter, versionId } when a normal chapter is clicked (with onChapterPickerPress)', async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + + render( + + + + , + ); + + await user.click(screen.getByText('2')); + + expect(onSelect).toHaveBeenCalledTimes(1); + const payload = onSelect.mock.calls[0]![0] as BibleChapterPickerSelectData; + expect(payload).toEqual({ + book: 'GEN', + chapter: '2', + versionId: 3034, + }); + }); + + it('calls onSelect when intro chapter is clicked (with onChapterPickerPress)', async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + + render( + + + + , + ); + + const introButton = screen + .getAllByRole('button') + .find((b) => b.querySelector('svg') && !b.textContent?.trim()); + if (introButton) await user.click(introButton); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith({ + book: 'GEN', + chapter: 'INTRO', + versionId: 3034, + }); + }); + + it('preserves default popover behavior when onSelect is not provided', async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + await user.click(screen.getByRole('button')); + expect(await screen.findByText('Books')).toBeInTheDocument(); + + await user.click(screen.getByText('2')); + + await waitFor(() => { + expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/ui/src/components/bible-chapter-picker.tsx b/packages/ui/src/components/bible-chapter-picker.tsx index 0276c9ec..17b83dad 100644 --- a/packages/ui/src/components/bible-chapter-picker.tsx +++ b/packages/ui/src/components/bible-chapter-picker.tsx @@ -27,8 +27,14 @@ export interface BibleChapterPickerPressData { versionId: number; } +export type BibleChapterPickerSelectData = Pick< + BibleChapterPickerPressData, + 'book' | 'chapter' | 'versionId' +>; + export type BibleChapterPickerContentProps = { onRequestClose?: () => void; + onSelect?: (data: BibleChapterPickerSelectData) => void; }; type BibleChapterPickerContextType = { @@ -285,7 +291,7 @@ function Trigger({ asChild = true, children, ...props }: TriggerProps) { ); } -function Content({ onRequestClose }: BibleChapterPickerContentProps) { +function Content({ onRequestClose, onSelect }: BibleChapterPickerContentProps) { const { book, defaultBook, @@ -297,6 +303,7 @@ function Content({ onRequestClose }: BibleChapterPickerContentProps) { registerBookElement, setBook, setChapter, + versionId, } = useBibleChapterPickerContext(); const handleChapterButtonClick = (bookId: string, passageId: string) => { @@ -305,6 +312,7 @@ function Content({ onRequestClose }: BibleChapterPickerContentProps) { setBook(bookId); setChapter(chapterId); setSearchQuery(''); + onSelect?.({ book: bookId, chapter: chapterId, versionId }); onRequestClose?.(); } }; diff --git a/packages/ui/src/components/bible-reader.test.tsx b/packages/ui/src/components/bible-reader.test.tsx index f983ce18..cc7616af 100644 --- a/packages/ui/src/components/bible-reader.test.tsx +++ b/packages/ui/src/components/bible-reader.test.tsx @@ -264,3 +264,38 @@ describe('BibleReader theme settings', () => { expect(nextSnap.fontFamily).toBe(INTER_FONT); }); }); + +describe('BibleReader Toolbar - onChapterPickerPress', () => { + beforeEach(() => { + vi.clearAllMocks(); + setupDefaultMocks(); + }); + + it('calls onChapterPickerPress from Root when chapter nav button is clicked and hides popover', async () => { + const user = userEvent.setup(); + const onChapterPickerPress = vi.fn(); + + render( + + + , + ); + + const chapterButton = screen.getByRole('button', { name: 'Change Bible book and chapter' }); + await user.click(chapterButton); + + expect(onChapterPickerPress).toHaveBeenCalledTimes(1); + expect(onChapterPickerPress).toHaveBeenCalledWith({ + book: 'JHN', + chapter: '1', + versionId: 3034, + }); + + expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index d4af5025..1136ad7e 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -21,7 +21,7 @@ import { } from 'react'; import { cn } from '@/lib/utils'; import { DEFAULT_LICENSE_FREE_BIBLE_VERSION, getAdjacentChapter } from '@youversion/platform-core'; -import { BibleChapterPicker } from './bible-chapter-picker'; +import { BibleChapterPicker, type BibleChapterPickerPressData } from './bible-chapter-picker'; import { BibleVersionPicker } from './bible-version-picker'; import { GearIcon } from './icons/gear'; import { InfoIcon } from './icons/info'; @@ -51,6 +51,7 @@ type BibleReaderContextType = { showVerseNumbers: boolean; background: 'light' | 'dark'; onFootnotePress?: (data: FootnoteData) => void; + onChapterPickerPress?: (data: BibleChapterPickerPressData) => void; }; const BibleReaderContext = createContext(null); @@ -83,6 +84,7 @@ export type RootProps = { showVerseNumbers?: boolean; background?: 'light' | 'dark'; onFootnotePress?: (data: FootnoteData) => void; + onChapterPickerPress?: (data: BibleChapterPickerPressData) => void; children?: ReactNode; }; @@ -181,6 +183,7 @@ function Root({ showVerseNumbers = true, background, onFootnotePress, + onChapterPickerPress, children, }: RootProps) { const [book, setBook] = useControllableState({ @@ -286,6 +289,7 @@ function Root({ showVerseNumbers, background: theme, onFootnotePress, + onChapterPickerPress, }; return ( @@ -552,6 +556,7 @@ function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolba currentFontSize, setCurrentFontSize, background, + onChapterPickerPress, } = useBibleReaderContext(); const yvContext = useContext(YouVersionContext); const themesSettingsValuesRef = useRef({ @@ -638,6 +643,7 @@ function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolba onChapterChange={setChapter} versionId={versionId} background={background} + onChapterPickerPress={onChapterPickerPress} > {({ chapterLabel, currentBook, loading }) => ( diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index e5ecb850..c3f5651d 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -2,6 +2,7 @@ export { BibleChapterPicker, type RootProps, type BibleChapterPickerRootProps, + type BibleChapterPickerSelectData, type TriggerProps, type BibleChapterPickerContentProps, type BibleChapterPickerPressData, From d6df084ab0edd4d1e9eb029cdd93699bab58f0bd Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Tue, 12 May 2026 09:59:53 -0500 Subject: [PATCH 2/3] chore: add changeset for BibleChapterPicker onSelect + BibleReader onChapterPickerPress --- ...pter-picker-on-select-and-reader-context-threading.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/chapter-picker-on-select-and-reader-context-threading.md diff --git a/.changeset/chapter-picker-on-select-and-reader-context-threading.md b/.changeset/chapter-picker-on-select-and-reader-context-threading.md new file mode 100644 index 00000000..f2d6c32b --- /dev/null +++ b/.changeset/chapter-picker-on-select-and-reader-context-threading.md @@ -0,0 +1,9 @@ +--- +"@youversion/platform-react-ui": minor +--- + +Add `onSelect` callback to `BibleChapterPicker.Content` and `onChapterPickerPress` to `BibleReader.Root` + +- `BibleChapterPickerSelectData` type exported for `onSelect` payload +- `onSelect` prop on `Content` fires after internal state updates, before `onRequestClose` +- `onChapterPickerPress` prop on `BibleReader.Root` threaded through context to `Toolbar`, suppressing default popover when provided From fa679e1512640f416a2adc1a6a3fcbfa9f17cf81 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Tue, 12 May 2026 10:19:07 -0500 Subject: [PATCH 3/3] =?UTF-8?q?fix(ui):=20address=20greptile=20review=20?= =?UTF-8?q?=E2=80=94=20simplify=20type=20alias=20+=20stable=20intro=20butt?= =?UTF-8?q?on=20selector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ui/src/components/bible-chapter-picker.test.tsx | 6 ++---- packages/ui/src/components/bible-chapter-picker.tsx | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/bible-chapter-picker.test.tsx b/packages/ui/src/components/bible-chapter-picker.test.tsx index bdae22c7..7b96b866 100644 --- a/packages/ui/src/components/bible-chapter-picker.test.tsx +++ b/packages/ui/src/components/bible-chapter-picker.test.tsx @@ -166,10 +166,8 @@ describe('BibleChapterPicker.Content onSelect', () => { , ); - const introButton = screen - .getAllByRole('button') - .find((b) => b.querySelector('svg') && !b.textContent?.trim()); - if (introButton) await user.click(introButton); + const introButton = screen.getByTestId('intro-chapter-button'); + await user.click(introButton); expect(onSelect).toHaveBeenCalledTimes(1); expect(onSelect).toHaveBeenCalledWith({ diff --git a/packages/ui/src/components/bible-chapter-picker.tsx b/packages/ui/src/components/bible-chapter-picker.tsx index 17b83dad..aa4f2b5e 100644 --- a/packages/ui/src/components/bible-chapter-picker.tsx +++ b/packages/ui/src/components/bible-chapter-picker.tsx @@ -27,10 +27,7 @@ export interface BibleChapterPickerPressData { versionId: number; } -export type BibleChapterPickerSelectData = Pick< - BibleChapterPickerPressData, - 'book' | 'chapter' | 'versionId' ->; +export type BibleChapterPickerSelectData = BibleChapterPickerPressData; export type BibleChapterPickerContentProps = { onRequestClose?: () => void; @@ -345,6 +342,7 @@ function Content({ onRequestClose, onSelect }: BibleChapterPickerContentProps) { key={`${bookItem.id}-${bookItem.intro.passage_id}`} variant="secondary" size="icon" + data-testid="intro-chapter-button" className="yv:aspect-square yv:w-full yv:h-full yv:flex yv:items-center yv:justify-center yv:rounded-[4px]" onClick={() => handleChapterButtonClick(bookItem.id, bookItem.intro?.passage_id || '')