From 1538f8212e8ba50927a7b3341e44e336be64f875 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Tue, 5 May 2026 12:55:05 -0500 Subject: [PATCH 1/6] feat(ui): export BibleChapterPicker.Content and add onChapterPickerPress for custom handling --- .changeset/quick-baboons-stare.md | 6 + .../components/bible-chapter-picker.test.tsx | 117 +++++++ .../src/components/bible-chapter-picker.tsx | 321 ++++++++++++------ packages/ui/src/components/index.ts | 2 + 4 files changed, 333 insertions(+), 113 deletions(-) create mode 100644 .changeset/quick-baboons-stare.md create mode 100644 packages/ui/src/components/bible-chapter-picker.test.tsx diff --git a/.changeset/quick-baboons-stare.md b/.changeset/quick-baboons-stare.md new file mode 100644 index 00000000..c94ddea7 --- /dev/null +++ b/.changeset/quick-baboons-stare.md @@ -0,0 +1,6 @@ +--- +"@youversion/platform-react-ui": minor +--- + +Export `BibleChapterPicker.Content` for standalone rendering and add `onChapterPickerPress` to intercept trigger presses (e.g. to render picker content in a native bottom sheet instead of a popover). + diff --git a/packages/ui/src/components/bible-chapter-picker.test.tsx b/packages/ui/src/components/bible-chapter-picker.test.tsx new file mode 100644 index 00000000..6fdac5ac --- /dev/null +++ b/packages/ui/src/components/bible-chapter-picker.test.tsx @@ -0,0 +1,117 @@ +/** + * @vitest-environment jsdom + */ +// 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 userEvent from '@testing-library/user-event'; + +// ResizeObserver is used by @floating-ui/dom (Radix Popover) +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} +globalThis.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; + +import { BibleChapterPicker } from './bible-chapter-picker'; +import { useBooks, useTheme } from '@youversion/platform-react-hooks'; +import type { BibleBook } from '@youversion/platform-core'; + +vi.mock('@youversion/platform-react-hooks'); + +const mockBooks: BibleBook[] = [ + { + id: 'GEN', + title: 'Genesis', + full_title: 'The First Book of Moses, Commonly Called Genesis', + canon: 'old_testament', + abbreviation: 'Gen', + intro: { id: 'INTRO', passage_id: 'GEN.0.INTRO', title: 'Intro' }, + chapters: [ + { id: '1', title: '1', passage_id: 'GEN.1' }, + { id: '2', title: '2', passage_id: 'GEN.2' }, + ], + }, +]; + +function setupDefaultMocks() { + vi.mocked(useTheme).mockReturnValue('light'); + vi.mocked(useBooks).mockReturnValue({ + books: { data: [...mockBooks], next_page_token: null }, + loading: false, + error: null, + refetch: vi.fn(), + }); +} + +describe('BibleChapterPicker - onChapterPickerPress override', () => { + beforeEach(() => { + vi.clearAllMocks(); + setupDefaultMocks(); + }); + + it('calls onChapterPickerPress with { book, chapter, versionId } when Trigger is clicked', async () => { + const user = userEvent.setup(); + const onChapterPickerPress = vi.fn(); + + render( + + + , + ); + + await user.click(screen.getByRole('button')); + + expect(onChapterPickerPress).toHaveBeenCalledTimes(1); + expect(onChapterPickerPress).toHaveBeenCalledWith({ + book: 'GEN', + chapter: '1', + versionId: 3034, + }); + }); + + it('does NOT render popover content when onChapterPickerPress is provided', () => { + render( + + + , + ); + + expect(screen.queryByText('Books')).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument(); + }); +}); + +describe('BibleChapterPicker - default popover mode', () => { + beforeEach(() => { + vi.clearAllMocks(); + setupDefaultMocks(); + }); + + it('renders popover content when Trigger is clicked and no override is provided', async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + await user.click(screen.getByRole('button')); + + expect(await screen.findByText('Books')).toBeInTheDocument(); + expect(await screen.findByPlaceholderText('Search')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/bible-chapter-picker.tsx b/packages/ui/src/components/bible-chapter-picker.tsx index 67066b8a..6b262554 100644 --- a/packages/ui/src/components/bible-chapter-picker.tsx +++ b/packages/ui/src/components/bible-chapter-picker.tsx @@ -1,10 +1,14 @@ +'use client'; + import { + cloneElement, createContext, + isValidElement, useContext, - useState, useEffect, useMemo, useRef, + useState, type ReactNode, } from 'react'; import { useControllableState } from '@radix-ui/react-use-controllable-state'; @@ -13,10 +17,20 @@ import { type BibleBook } from '@youversion/platform-core'; import { InfoIcon } from './icons/info'; import { SearchIcon } from './icons/search'; import { Button } from './ui/button'; -import { Popover, PopoverTrigger, PopoverContent, PopoverClose } from './ui/popover'; +import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from './ui/accordion'; import { InputGroup, InputGroupInput, InputGroupAddon } from './ui/input-group'; +export interface BibleChapterPickerPressData { + book: string; + chapter: string; + versionId: number; +} + +export type BibleChapterPickerContentProps = { + onRequestClose?: () => void; +}; + type BibleChapterPickerContextType = { book: string; chapter: string; @@ -25,6 +39,14 @@ type BibleChapterPickerContextType = { versionId: number; background: 'light' | 'dark'; scrollToCurrentBook: () => void; + defaultBook: string; + searchQuery: string; + setSearchQuery: (query: string) => void; + expandedBook: string | null; + setExpandedBook: (bookId: string | null) => void; + filteredBooks: BibleBook[] | null; + registerBookElement: (bookId: string, node: HTMLDivElement | null) => void; + onChapterPickerPress?: (data: BibleChapterPickerPressData) => void; }; const BibleChapterPickerContext = createContext(null); @@ -46,6 +68,7 @@ export type RootProps = { onChapterChange?: (chapter: string) => void; versionId: number; background?: 'light' | 'dark'; + onChapterPickerPress?: (data: BibleChapterPickerPressData) => void; children?: ReactNode; }; @@ -60,6 +83,7 @@ function Root({ onChapterChange, versionId, background, + onChapterPickerPress, children, }: RootProps) { const [book, setBook] = useControllableState({ @@ -77,6 +101,7 @@ function Root({ const providerTheme = useTheme(); const theme = background || providerTheme; + const [isPopoverOpen, setIsPopoverOpenRaw] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [expandedBook, setExpandedBook] = useState(book || null); @@ -92,6 +117,14 @@ function Root({ const bookElementsRef = useRef>({}); + const registerBookElement = (bookId: string, node: HTMLDivElement | null) => { + if (node) { + bookElementsRef.current[bookId] = node; + } else { + delete bookElementsRef.current[bookId]; + } + }; + const scrollToCurrentBook = () => { if (book) { setExpandedBook(book); @@ -124,122 +157,43 @@ function Root({ }; }, [expandedBook]); - const handleChapterButtonClick = (bookId: string, passageId: string) => { - const chapterId = passageId.split('.').pop() || ''; - if (chapterId && bookId) { - setBook(bookId); - setChapter(chapterId); + const setIsPopoverOpen = (open: boolean) => { + setIsPopoverOpenRaw(open); + if (!open) { setSearchQuery(''); } }; - return ( - - + const contextValue: BibleChapterPickerContextType = { + book, + chapter, + setBook, + setChapter, + versionId, + background: theme, + scrollToCurrentBook, + defaultBook, + searchQuery, + setSearchQuery, + expandedBook, + setExpandedBook, + filteredBooks, + registerBookElement, + onChapterPickerPress, + }; + + return onChapterPickerPress ? ( + + {children} + + ) : ( + + {children} {/* data-yv-sdk for styles is needed because the popover gets rendered outside of the providers scope **/} - - {filteredBooks && filteredBooks.length > 0 ? ( - filteredBooks.map((bookItem) => ( - { - if (node) { - bookElementsRef.current[bookItem.id] = node; - } else { - delete bookElementsRef.current[bookItem.id]; - } - }} - > - {bookItem.title} - - {bookItem.chapters && bookItem.chapters.length > 0 ? ( -
- {bookItem.intro?.id && bookItem.intro?.passage_id ? ( - - - - ) : null} - {bookItem.chapters.map((chapterRef) => { - const chapterId = chapterRef.passage_id.split('.').pop() || ''; - return ( - - - - ); - })} -
- ) : ( -
- No chapters available -
- )} -
-
- )) - ) : ( -
- We're sorry, there are no Bible results for this search. -
- )} -
- -
- - setSearchQuery(e.target.value)} - /> - - - - -
+ setIsPopoverOpen(false)} />
@@ -261,7 +215,7 @@ export type TriggerProps = Omit, 'ch }; function Trigger({ asChild = true, children, ...props }: TriggerProps) { - const { book, chapter, background, versionId, scrollToCurrentBook } = + const { book, chapter, background, versionId, scrollToCurrentBook, onChapterPickerPress } = useBibleChapterPickerContext(); const { books, loading } = useBooks(versionId); const providerTheme = useTheme(); @@ -282,17 +236,158 @@ function Trigger({ asChild = true, children, ...props }: TriggerProps) { ? children({ book, chapter, chapterLabel, currentBook, loading }) : children || ; + const handlePress = (event: React.MouseEvent) => { + props.onClick?.(event); + scrollToCurrentBook(); + if (onChapterPickerPress) { + onChapterPickerPress({ book, chapter, versionId }); + } + }; + + if (onChapterPickerPress) { + if (asChild && isValidElement>(content)) { + return cloneElement(content, { + 'data-yv-sdk': true, + 'data-yv-theme': theme, + ...props, + onClick: handlePress, + }); + } + + return ( + + ); + } + + const handleOpenPopover = (event: React.MouseEvent) => { + props.onClick?.(event); + scrollToCurrentBook(); + }; + return ( {content} ); } -export const BibleChapterPicker = Object.assign({}, { Root, Trigger }); +function Content({ onRequestClose }: BibleChapterPickerContentProps) { + const { + book, + background, + defaultBook, + filteredBooks, + expandedBook, + setExpandedBook, + searchQuery, + setSearchQuery, + registerBookElement, + setBook, + setChapter, + } = useBibleChapterPickerContext(); + + const handleChapterButtonClick = (bookId: string, passageId: string) => { + const chapterId = passageId.split('.').pop() || ''; + if (chapterId && bookId) { + setBook(bookId); + setChapter(chapterId); + setSearchQuery(''); + onRequestClose?.(); + } + }; + + return ( +
+ setExpandedBook(value || null)} + > + {filteredBooks && filteredBooks.length > 0 ? ( + filteredBooks.map((bookItem) => ( + registerBookElement(bookItem.id, node)} + > + {bookItem.title} + + {bookItem.chapters && bookItem.chapters.length > 0 ? ( +
+ {bookItem.intro?.id && bookItem.intro?.passage_id ? ( + + ) : null} + {bookItem.chapters.map((chapterRef) => { + const chapterId = chapterRef.passage_id.split('.').pop() || ''; + return ( + + ); + })} +
+ ) : ( +
+ No chapters available +
+ )} +
+
+ )) + ) : ( +
+ We're sorry, there are no Bible results for this search. +
+ )} +
+ +
+ + setSearchQuery(e.target.value)} + /> + + + + +
+
+ ); +} + +export const BibleChapterPicker = Object.assign({}, { Root, Trigger, Content }); diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 1da71edf..cdcd1491 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -3,6 +3,8 @@ export { type RootProps, type BibleChapterPickerRootProps, type TriggerProps, + type BibleChapterPickerContentProps, + type BibleChapterPickerPressData, } from './bible-chapter-picker'; export { BibleReader, type BibleReaderRootProps } from './bible-reader'; export { From 13a3dee7796c669fc04fa0fc3a67e4367a745d42 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Tue, 5 May 2026 13:16:06 -0500 Subject: [PATCH 2/6] fix(ui): update default value handling in BibleChapterPicker.Content to improve state management --- packages/ui/src/components/bible-chapter-picker.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/ui/src/components/bible-chapter-picker.tsx b/packages/ui/src/components/bible-chapter-picker.tsx index 6b262554..1e2ee1fc 100644 --- a/packages/ui/src/components/bible-chapter-picker.tsx +++ b/packages/ui/src/components/bible-chapter-picker.tsx @@ -281,9 +281,7 @@ function Trigger({ asChild = true, children, ...props }: TriggerProps) { function Content({ onRequestClose }: BibleChapterPickerContentProps) { const { - book, background, - defaultBook, filteredBooks, expandedBook, setExpandedBook, @@ -310,8 +308,7 @@ function Content({ onRequestClose }: BibleChapterPickerContentProps) { className="yv:relative yv:overflow-y-auto yv:bg-background yv:px-6" type="single" collapsible - defaultValue={defaultBook || book || 'GEN'} - value={expandedBook ?? undefined} + value={expandedBook ?? ''} onValueChange={(value) => setExpandedBook(value || null)} > {filteredBooks && filteredBooks.length > 0 ? ( From 032087f8f5a03c7ee46272a262eadcc148791dd8 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Tue, 5 May 2026 13:17:20 -0500 Subject: [PATCH 3/6] fix(ui): ensure scrollToCurrentBook is called when onChapterPickerPress is not provided --- packages/ui/src/components/bible-chapter-picker.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/bible-chapter-picker.tsx b/packages/ui/src/components/bible-chapter-picker.tsx index 1e2ee1fc..dc0e898e 100644 --- a/packages/ui/src/components/bible-chapter-picker.tsx +++ b/packages/ui/src/components/bible-chapter-picker.tsx @@ -238,9 +238,10 @@ function Trigger({ asChild = true, children, ...props }: TriggerProps) { const handlePress = (event: React.MouseEvent) => { props.onClick?.(event); - scrollToCurrentBook(); if (onChapterPickerPress) { onChapterPickerPress({ book, chapter, versionId }); + } else { + scrollToCurrentBook(); } }; From 4e4036fe547d45a461972958dc8f724ad8033fe6 Mon Sep 17 00:00:00 2001 From: Dustin Kelley Date: Tue, 5 May 2026 13:44:48 -0500 Subject: [PATCH 4/6] fix(ui): scroll style overflow --- packages/ui/src/components/bible-chapter-picker.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/bible-chapter-picker.tsx b/packages/ui/src/components/bible-chapter-picker.tsx index dc0e898e..4b70da83 100644 --- a/packages/ui/src/components/bible-chapter-picker.tsx +++ b/packages/ui/src/components/bible-chapter-picker.tsx @@ -282,7 +282,6 @@ function Trigger({ asChild = true, children, ...props }: TriggerProps) { function Content({ onRequestClose }: BibleChapterPickerContentProps) { const { - background, filteredBooks, expandedBook, setExpandedBook, @@ -304,7 +303,7 @@ function Content({ onRequestClose }: BibleChapterPickerContentProps) { }; return ( -
+ <> -
+ ); } From cd5013a3dd3eee59c24f21825d6eeb00eef3ecf6 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Tue, 5 May 2026 14:01:42 -0500 Subject: [PATCH 5/6] feat(hooks): Add exclude to vitest config Excludes dist and node_modules from Vitest tests to prevent unexpected behavior. --- packages/hooks/vitest.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/hooks/vitest.config.ts b/packages/hooks/vitest.config.ts index 64a97bfb..50157f0f 100644 --- a/packages/hooks/vitest.config.ts +++ b/packages/hooks/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ globals: true, mockReset: true, unstubGlobals: true, + exclude: ['**/dist/**', '**/node_modules/**'], coverage: { provider: 'v8', reporter: ['text', 'json-summary'], From de4d87f2d90b8ddbd8fad3a2b003dde23c3db627 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Tue, 5 May 2026 14:13:45 -0500 Subject: [PATCH 6/6] Fix scrolling and default book in Bible Chapter Picker Add checks for `scrollIntoView` availability and set a default book for the picker to improve user experience and robustness. --- .../src/components/bible-chapter-picker.tsx | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/components/bible-chapter-picker.tsx b/packages/ui/src/components/bible-chapter-picker.tsx index 4b70da83..0276c9ec 100644 --- a/packages/ui/src/components/bible-chapter-picker.tsx +++ b/packages/ui/src/components/bible-chapter-picker.tsx @@ -129,10 +129,13 @@ function Root({ if (book) { setExpandedBook(book); setTimeout(() => { - bookElementsRef.current[book]?.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); + const element = bookElementsRef.current[book]; + if (element && typeof element.scrollIntoView === 'function') { + element.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + } }, 200); } }; @@ -144,10 +147,13 @@ function Root({ const controller = new AbortController(); const timeoutId = setTimeout(() => { if (!controller.signal.aborted) { - bookElementsRef.current[expandedBook]?.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); + const element = bookElementsRef.current[expandedBook]; + if (element && typeof element.scrollIntoView === 'function') { + element.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + } } }, 200); @@ -238,10 +244,9 @@ function Trigger({ asChild = true, children, ...props }: TriggerProps) { const handlePress = (event: React.MouseEvent) => { props.onClick?.(event); + scrollToCurrentBook(); if (onChapterPickerPress) { onChapterPickerPress({ book, chapter, versionId }); - } else { - scrollToCurrentBook(); } }; @@ -282,6 +287,8 @@ function Trigger({ asChild = true, children, ...props }: TriggerProps) { function Content({ onRequestClose }: BibleChapterPickerContentProps) { const { + book, + defaultBook, filteredBooks, expandedBook, setExpandedBook, @@ -308,7 +315,8 @@ function Content({ onRequestClose }: BibleChapterPickerContentProps) { className="yv:relative yv:overflow-y-auto yv:bg-background yv:px-6" type="single" collapsible - value={expandedBook ?? ''} + defaultValue={defaultBook || book || 'GEN'} + value={expandedBook ?? undefined} onValueChange={(value) => setExpandedBook(value || null)} > {filteredBooks && filteredBooks.length > 0 ? (