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
diff --git a/packages/ui/src/components/bible-chapter-picker.test.tsx b/packages/ui/src/components/bible-chapter-picker.test.tsx
index 6fdac5ac..7b96b866 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,83 @@ 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.getByTestId('intro-chapter-button');
+ 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..aa4f2b5e 100644
--- a/packages/ui/src/components/bible-chapter-picker.tsx
+++ b/packages/ui/src/components/bible-chapter-picker.tsx
@@ -27,8 +27,11 @@ export interface BibleChapterPickerPressData {
versionId: number;
}
+export type BibleChapterPickerSelectData = BibleChapterPickerPressData;
+
export type BibleChapterPickerContentProps = {
onRequestClose?: () => void;
+ onSelect?: (data: BibleChapterPickerSelectData) => void;
};
type BibleChapterPickerContextType = {
@@ -285,7 +288,7 @@ function Trigger({ asChild = true, children, ...props }: TriggerProps) {
);
}
-function Content({ onRequestClose }: BibleChapterPickerContentProps) {
+function Content({ onRequestClose, onSelect }: BibleChapterPickerContentProps) {
const {
book,
defaultBook,
@@ -297,6 +300,7 @@ function Content({ onRequestClose }: BibleChapterPickerContentProps) {
registerBookElement,
setBook,
setChapter,
+ versionId,
} = useBibleChapterPickerContext();
const handleChapterButtonClick = (bookId: string, passageId: string) => {
@@ -305,6 +309,7 @@ function Content({ onRequestClose }: BibleChapterPickerContentProps) {
setBook(bookId);
setChapter(chapterId);
setSearchQuery('');
+ onSelect?.({ book: bookId, chapter: chapterId, versionId });
onRequestClose?.();
}
};
@@ -337,6 +342,7 @@ function Content({ onRequestClose }: 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 || '')
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,