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/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'], 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..0276c9ec 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,14 +117,25 @@ 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); 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); } }; @@ -111,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); @@ -124,122 +163,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 +221,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 +242,157 @@ 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, + 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 {