From d001c70f82e7cb77cdb36118b1686e7817dd7806 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Fri, 15 May 2026 14:24:49 -0500 Subject: [PATCH 1/5] feat(ui): add onVersionPickerPress escape hatch + controlled BibleCard versionId - BibleReader.Root: onVersionPickerPress threaded through context to Toolbar - BibleCard: controlled versionId via useControllableState, onVersionChange, onVersionPickerPress - BibleVersionPicker: guard isPopoverOpen when escape hatch active, move filteredRecentVersions to context - BibleChapterPicker: guard isPopoverOpen when onChapterPickerPress active - Remove BibleWidgetView (zero consumers) - Export BibleVersionPickerPressData type YPE-2195 --- ...-escape-hatch-and-bible-card-controlled.md | 12 +++++ packages/ui/src/components/bible-card.tsx | 27 ++++++++--- .../src/components/bible-chapter-picker.tsx | 4 +- packages/ui/src/components/bible-reader.tsx | 8 +++- .../src/components/bible-version-picker.tsx | 45 ++++++++----------- packages/ui/src/components/index.ts | 1 - 6 files changed, 63 insertions(+), 34 deletions(-) create mode 100644 .changeset/version-picker-escape-hatch-and-bible-card-controlled.md 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..6d236a52 --- /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` removed (zero consumers) +- `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..d3698797 100644 --- a/packages/ui/src/components/bible-card.tsx +++ b/packages/ui/src/components/bible-card.tsx @@ -1,9 +1,10 @@ import { usePassage, useVersion, useTheme } from '@youversion/platform-react-hooks'; 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 +30,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 +66,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 }) => ( @@ -111,13 +118,22 @@ function BibleCardFooter({ copyright }: { copyright?: string | null }): React.Re ); } +const DEFAULT_VERSION_ID = 3034; + export function BibleCard({ reference, - versionId, + versionId: controlledVersionId, + defaultVersionId = DEFAULT_VERSION_ID, + onVersionChange, background, showVersionPicker = false, + onVersionPickerPress, }: BibleCardProps): React.ReactNode { - const [versionNum, setVersionNum] = useState(versionId); + const [versionNum, setVersionNum] = useControllableState({ + prop: controlledVersionId, + defaultProp: defaultVersionId, + onChange: onVersionChange, + }); const { version } = useVersion(versionNum); const { passage, @@ -161,6 +177,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..5ba73ca8 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({ diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index c3f5651d..ee337a01 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -42,6 +42,5 @@ export { type FootnoteContentProps, } from './verse'; export { BibleCard, type BibleCardProps } from './bible-card'; -export { BibleWidgetView, type BibleWidgetViewProps } from './bible-widget-view'; export { Separator } from './ui/separator'; export { Textarea } from './ui/textarea'; From 9dba304d70fac671169f5590ac82d99d311d4be9 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Fri, 15 May 2026 14:58:33 -0500 Subject: [PATCH 2/5] fix(ui): keep BibleWidgetView as deprecated alias --- .../version-picker-escape-hatch-and-bible-card-controlled.md | 2 +- packages/ui/src/components/index.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/version-picker-escape-hatch-and-bible-card-controlled.md b/.changeset/version-picker-escape-hatch-and-bible-card-controlled.md index 6d236a52..6f84873b 100644 --- a/.changeset/version-picker-escape-hatch-and-bible-card-controlled.md +++ b/.changeset/version-picker-escape-hatch-and-bible-card-controlled.md @@ -8,5 +8,5 @@ Add `onVersionPickerPress` escape-hatch prop to `BibleReader.Root` and `BibleCar - `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` removed (zero consumers) +- `BibleWidgetView` kept as a deprecated alias for `BibleCard` - `BibleVersionPickerPressData` type exported: `{ versionId: number; languageId: string }` diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index ee337a01..c3f5651d 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -42,5 +42,6 @@ export { type FootnoteContentProps, } from './verse'; export { BibleCard, type BibleCardProps } from './bible-card'; +export { BibleWidgetView, type BibleWidgetViewProps } from './bible-widget-view'; export { Separator } from './ui/separator'; export { Textarea } from './ui/textarea'; From cdb861bd02dac9bc75229fdb6fb9335e19dab187 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Fri, 15 May 2026 15:15:58 -0500 Subject: [PATCH 3/5] fix(ui): preserve uncontrolled BibleCard versionId for backwards compatibility --- packages/ui/src/components/bible-card.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/bible-card.tsx b/packages/ui/src/components/bible-card.tsx index d3698797..ce0b56ac 100644 --- a/packages/ui/src/components/bible-card.tsx +++ b/packages/ui/src/components/bible-card.tsx @@ -129,9 +129,14 @@ export function BibleCard({ showVersionPicker = false, onVersionPickerPress, }: BibleCardProps): React.ReactNode { + // 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: controlledVersionId, - defaultProp: defaultVersionId, + prop: isControlled ? controlledVersionId : undefined, + defaultProp: isControlled ? defaultVersionId : (controlledVersionId ?? defaultVersionId), onChange: onVersionChange, }); const { version } = useVersion(versionNum); From 49dc4423ac5f02b45d831a61ae68c8f614d3db23 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Fri, 15 May 2026 15:18:20 -0500 Subject: [PATCH 4/5] fix(ui): return null from Content when escape hatch active without controlled props --- packages/ui/src/components/bible-version-picker.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/bible-version-picker.tsx b/packages/ui/src/components/bible-version-picker.tsx index 5ba73ca8..d2f64f7a 100644 --- a/packages/ui/src/components/bible-version-picker.tsx +++ b/packages/ui/src/components/bible-version-picker.tsx @@ -543,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 ( Date: Fri, 15 May 2026 15:35:06 -0500 Subject: [PATCH 5/5] refactor(ui): use shared DEFAULT_LICENSE_FREE_BIBLE_VERSION constant --- packages/ui/src/components/bible-card.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/bible-card.tsx b/packages/ui/src/components/bible-card.tsx index ce0b56ac..9d5dde6c 100644 --- a/packages/ui/src/components/bible-card.tsx +++ b/packages/ui/src/components/bible-card.tsx @@ -1,4 +1,5 @@ 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, type BibleVersionPickerPressData } from './bible-version-picker'; @@ -118,12 +119,10 @@ function BibleCardFooter({ copyright }: { copyright?: string | null }): React.Re ); } -const DEFAULT_VERSION_ID = 3034; - export function BibleCard({ reference, versionId: controlledVersionId, - defaultVersionId = DEFAULT_VERSION_ID, + defaultVersionId = DEFAULT_LICENSE_FREE_BIBLE_VERSION, onVersionChange, background, showVersionPicker = false,