diff --git a/.changeset/version-picker-escape-hatch-and-bible-card-controlled.md b/.changeset/version-picker-escape-hatch-and-bible-card-controlled.md new file mode 100644 index 00000000..6f84873b --- /dev/null +++ b/.changeset/version-picker-escape-hatch-and-bible-card-controlled.md @@ -0,0 +1,12 @@ +--- +"@youversion/platform-react-ui": minor +--- + +Add `onVersionPickerPress` escape-hatch prop to `BibleReader.Root` and `BibleCard`, and make `BibleCard.versionId` controllable. + +- `BibleReader.Root` accepts `onVersionPickerPress?: (data: BibleVersionPickerPressData) => void`, threaded through context to Toolbar and then to the internal `BibleVersionPicker.Root` — suppresses the default popover when provided +- `BibleCard` accepts `onVersionPickerPress`, `defaultVersionId`, and `onVersionChange`; `versionId` is now optional and uses `useControllableState` for controlled/uncontrolled support +- `BibleVersionPicker.Root` guards `isPopoverOpen` state when escape hatch is active, moves `filteredRecentVersions` to context to eliminate duplication between `Content` and `BibleVersionPickerLanguageTrigger` +- `BibleChapterPicker.Root` guards `isPopoverOpen` state when `onChapterPickerPress` is active +- `BibleWidgetView` kept as a deprecated alias for `BibleCard` +- `BibleVersionPickerPressData` type exported: `{ versionId: number; languageId: string }` diff --git a/packages/ui/src/components/bible-card.tsx b/packages/ui/src/components/bible-card.tsx index fd47f71f..9d5dde6c 100644 --- a/packages/ui/src/components/bible-card.tsx +++ b/packages/ui/src/components/bible-card.tsx @@ -1,9 +1,11 @@ import { usePassage, useVersion, useTheme } from '@youversion/platform-react-hooks'; +import { DEFAULT_LICENSE_FREE_BIBLE_VERSION } from '@youversion/platform-core'; import { BibleTextView } from './verse'; import { BibleAppLogoLockup } from './bible-app-logo-lockup'; -import { BibleVersionPicker } from './bible-version-picker'; +import { BibleVersionPicker, type BibleVersionPickerPressData } from './bible-version-picker'; import { Button } from './ui/button'; -import { useState, useEffect } from 'react'; +import { useEffect, useState } from 'react'; +import { useControllableState } from '@radix-ui/react-use-controllable-state'; import { SOURCE_SERIF_FONT } from '@/lib/verse-html-utils'; import { LoaderIcon } from './icons/loader'; import { AnimatedHeight } from './animated-height'; @@ -29,9 +31,12 @@ type VersionResult = ReturnType; export type BibleCardProps = { reference: string; - versionId: number; + versionId?: number; + defaultVersionId?: number; + onVersionChange?: (versionId: number) => void; background?: 'light' | 'dark'; showVersionPicker?: boolean; + onVersionPickerPress?: (data: BibleVersionPickerPressData) => void; }; function BibleCardHeaderError(): React.ReactNode { @@ -62,16 +67,19 @@ function BibleCardVersionPicker({ versionId, onVersionChange, theme, + onVersionPickerPress, }: { versionId: number; onVersionChange: (id: number) => void; theme: 'light' | 'dark'; + onVersionPickerPress?: (data: BibleVersionPickerPressData) => void; }): React.ReactNode { return ( {({ version, loading }) => ( @@ -113,11 +121,23 @@ function BibleCardFooter({ copyright }: { copyright?: string | null }): React.Re export function BibleCard({ reference, - versionId, + versionId: controlledVersionId, + defaultVersionId = DEFAULT_LICENSE_FREE_BIBLE_VERSION, + onVersionChange, background, showVersionPicker = false, + onVersionPickerPress, }: BibleCardProps): React.ReactNode { - const [versionNum, setVersionNum] = useState(versionId); + // Controlled only when both versionId + onVersionChange are provided. + // versionId alone seeds uncontrolled state, preserving backwards compatibility + // with consumers who use the version picker without an onChange handler. + const isControlled = controlledVersionId !== undefined && onVersionChange !== undefined; + + const [versionNum, setVersionNum] = useControllableState({ + prop: isControlled ? controlledVersionId : undefined, + defaultProp: isControlled ? defaultVersionId : (controlledVersionId ?? defaultVersionId), + onChange: onVersionChange, + }); const { version } = useVersion(versionNum); const { passage, @@ -161,6 +181,7 @@ export function BibleCard({ versionId={versionNum} onVersionChange={setVersionNum} theme={theme} + onVersionPickerPress={onVersionPickerPress} /> ) : null} diff --git a/packages/ui/src/components/bible-chapter-picker.tsx b/packages/ui/src/components/bible-chapter-picker.tsx index aa4f2b5e..4a66bfff 100644 --- a/packages/ui/src/components/bible-chapter-picker.tsx +++ b/packages/ui/src/components/bible-chapter-picker.tsx @@ -104,7 +104,8 @@ function Root({ const providerTheme = useTheme(); const theme = background || providerTheme; - const [isPopoverOpen, setIsPopoverOpenRaw] = useState(false); + const [isPopoverOpenRaw, setIsPopoverOpenRaw] = useState(false); + const isPopoverOpen = onChapterPickerPress ? false : isPopoverOpenRaw; const [searchQuery, setSearchQuery] = useState(''); const [expandedBook, setExpandedBook] = useState(book || null); @@ -167,6 +168,7 @@ function Root({ }, [expandedBook]); const setIsPopoverOpen = (open: boolean) => { + if (onChapterPickerPress) return; setIsPopoverOpenRaw(open); if (!open) { setSearchQuery(''); diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index 1136ad7e..eaed2fb6 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -22,7 +22,7 @@ import { import { cn } from '@/lib/utils'; import { DEFAULT_LICENSE_FREE_BIBLE_VERSION, getAdjacentChapter } from '@youversion/platform-core'; import { BibleChapterPicker, type BibleChapterPickerPressData } from './bible-chapter-picker'; -import { BibleVersionPicker } from './bible-version-picker'; +import { BibleVersionPicker, type BibleVersionPickerPressData } from './bible-version-picker'; import { GearIcon } from './icons/gear'; import { InfoIcon } from './icons/info'; import { LoaderIcon } from './icons/loader'; @@ -52,6 +52,7 @@ type BibleReaderContextType = { background: 'light' | 'dark'; onFootnotePress?: (data: FootnoteData) => void; onChapterPickerPress?: (data: BibleChapterPickerPressData) => void; + onVersionPickerPress?: (data: BibleVersionPickerPressData) => void; }; const BibleReaderContext = createContext(null); @@ -85,6 +86,7 @@ export type RootProps = { background?: 'light' | 'dark'; onFootnotePress?: (data: FootnoteData) => void; onChapterPickerPress?: (data: BibleChapterPickerPressData) => void; + onVersionPickerPress?: (data: BibleVersionPickerPressData) => void; children?: ReactNode; }; @@ -184,6 +186,7 @@ function Root({ background, onFootnotePress, onChapterPickerPress, + onVersionPickerPress, children, }: RootProps) { const [book, setBook] = useControllableState({ @@ -290,6 +293,7 @@ function Root({ background: theme, onFootnotePress, onChapterPickerPress, + onVersionPickerPress, }; return ( @@ -557,6 +561,7 @@ function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolba setCurrentFontSize, background, onChapterPickerPress, + onVersionPickerPress, } = useBibleReaderContext(); const yvContext = useContext(YouVersionContext); const themesSettingsValuesRef = useRef({ @@ -711,6 +716,7 @@ function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolba versionId={versionId} onVersionChange={setVersionId} background={background} + onVersionPickerPress={onVersionPickerPress} > {({ version, loading }) => ( diff --git a/packages/ui/src/components/bible-version-picker.tsx b/packages/ui/src/components/bible-version-picker.tsx index a36d0366..d2f64f7a 100644 --- a/packages/ui/src/components/bible-version-picker.tsx +++ b/packages/ui/src/components/bible-version-picker.tsx @@ -148,6 +148,7 @@ type BibleVersionPickerContextType = { setSearchQuery: (query: string) => void; suggestedLanguages: Pick[]; filteredVersions: BibleVersion[]; + filteredRecentVersions: RecentVersion[]; isLanguagesOpen: boolean; setIsLanguagesOpen: (open: boolean) => void; recentVersions: RecentVersion[]; @@ -232,17 +233,19 @@ function Root({ const [searchQuery, setSearchQuery] = useState(''); const [isLanguagesOpen, setIsLanguagesOpen] = useState(false); const [recentVersions, setRecentVersions] = useState(getRecentVersions); - const [isPopoverOpen, setIsPopoverOpenRaw] = useState(false); + const [isPopoverOpenRaw, setIsPopoverOpenRaw] = useState(false); + const isPopoverOpen = onVersionPickerPress ? false : isPopoverOpenRaw; const setIsPopoverOpen = useCallback( (open: boolean) => { + if (onVersionPickerPress) return; setIsPopoverOpenRaw(open); if (!open) { setSearchQuery(''); setIsLanguagesOpen(false); } }, - [setSearchQuery], + [setSearchQuery, onVersionPickerPress], ); const addRecentVersion = useCallback((version: RecentVersion) => { @@ -271,6 +274,17 @@ function Root({ recentVersions, ); + const filteredRecentVersions = useMemo(() => { + if (!searchQuery.trim()) return recentVersions; + const query = searchQuery.trim().toLowerCase(); + return recentVersions.filter( + (v) => + v.title?.toLowerCase().includes(query) || + v.localized_abbreviation?.toLowerCase().includes(query) || + v.abbreviation?.toLowerCase().includes(query), + ); + }, [recentVersions, searchQuery]); + const getLanguageDisplayName = useCallback( (language: Pick) => { return ( @@ -345,6 +359,7 @@ function Root({ setSearchQuery, suggestedLanguages, filteredVersions, + filteredRecentVersions, isLanguagesOpen, setIsLanguagesOpen, recentVersions, @@ -444,22 +459,11 @@ export function BibleVersionPickerLanguageTrigger({ }: BibleVersionPickerLanguageTriggerProps): React.ReactElement { const { filteredVersions, + filteredRecentVersions, setIsLanguagesOpen, selectedLanguageId, - recentVersions, - searchQuery, versionsLoading, } = useBibleVersionPickerContext(); - const filteredRecentVersions = useMemo(() => { - if (!searchQuery.trim()) return recentVersions; - const query = searchQuery.trim().toLowerCase(); - return recentVersions.filter( - (v) => - v.title?.toLowerCase().includes(query) || - v.localized_abbreviation?.toLowerCase().includes(query) || - v.abbreviation?.toLowerCase().includes(query), - ); - }, [recentVersions, searchQuery]); // Fetch the selected language details (may not be in the paginated languages list) const { language: selectedLanguage } = useLanguage(selectedLanguageId); @@ -505,9 +509,9 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {}) searchQuery, setSearchQuery, filteredVersions, + filteredRecentVersions, versionId, setVersionId, - recentVersions, addRecentVersion, versionsLoading, background, @@ -519,17 +523,6 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {}) } = useBibleVersionPickerContext(); const wasOpenRef = useRef(open ?? false); - const filteredRecentVersions = useMemo(() => { - if (!searchQuery.trim()) return recentVersions; - const query = searchQuery.trim().toLowerCase(); - return recentVersions.filter( - (v) => - v.title?.toLowerCase().includes(query) || - v.localized_abbreviation?.toLowerCase().includes(query) || - v.abbreviation?.toLowerCase().includes(query), - ); - }, [recentVersions, searchQuery]); - const handleSelectVersion = (version: BibleVersion | RecentVersion) => { setVersionId(version.id); addRecentVersion({ @@ -550,7 +543,11 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {}) wasOpenRef.current = open ?? false; }, [open, setIsLanguagesOpen, setSearchQuery]); - if (!onVersionPickerPress && open === undefined && !onRequestClose) { + if (onVersionPickerPress && open === undefined && !onRequestClose) { + return null; + } + + if (open === undefined && !onRequestClose) { return (