diff --git a/.changeset/extract-version-picker-content.md b/.changeset/extract-version-picker-content.md new file mode 100644 index 00000000..7e240e3f --- /dev/null +++ b/.changeset/extract-version-picker-content.md @@ -0,0 +1,5 @@ +--- +"@youversion/platform-react-ui": minor +--- + +Add advanced Bible version picker composition surfaces for Expo DOM integrations. diff --git a/packages/ui/src/components/bible-version-picker.stories.tsx b/packages/ui/src/components/bible-version-picker.stories.tsx index a26d1091..f1f1d50a 100644 --- a/packages/ui/src/components/bible-version-picker.stories.tsx +++ b/packages/ui/src/components/bible-version-picker.stories.tsx @@ -151,7 +151,9 @@ export const InteractiveLanguageSelection: Story = { // Click language button const languageButton = await screen.findByRole('button', { name: /select language/i }); - await expect(languageButton).toHaveTextContent('English'); + await waitFor(async () => { + await expect(languageButton).toHaveTextContent('English'); + }); await userEvent.click(languageButton); // Verify language list is visible @@ -168,7 +170,6 @@ export const InteractiveLanguageSelection: Story = { await screen.findByRole('button', { name: /select language/i }), ).toHaveTextContent(/korean/i); }); - await userEvent.click(languageButton); }, }; diff --git a/packages/ui/src/components/bible-version-picker.test.tsx b/packages/ui/src/components/bible-version-picker.test.tsx index 51294de6..85d6aab2 100644 --- a/packages/ui/src/components/bible-version-picker.test.tsx +++ b/packages/ui/src/components/bible-version-picker.test.tsx @@ -14,7 +14,12 @@ class ResizeObserverMock { } globalThis.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; -import { BibleVersionPicker, RECENT_VERSIONS_KEY } from './bible-version-picker'; +import { + BibleLanguagePickerContent, + BibleVersionPicker, + BibleVersionPickerLanguageTrigger, + RECENT_VERSIONS_KEY, +} from './bible-version-picker'; import { useVersions, useVersion, @@ -23,7 +28,7 @@ import { useFilteredVersions, useTheme, } from '@youversion/platform-react-hooks'; -import type { BibleVersion } from '@youversion/platform-core'; +import type { BibleVersion, Language } from '@youversion/platform-core'; vi.mock('@youversion/platform-react-hooks'); @@ -50,6 +55,21 @@ const mockVersions: BibleVersion[] = [ }, ]; +const mockLanguages: Language[] = [ + { + id: 'en', + language: 'English', + display_names: { en: 'English' }, + speaking_population: 1500000000, + }, + { + id: 'es', + language: 'Spanish', + display_names: { en: 'Spanish', es: 'Español' }, + speaking_population: 500000000, + }, +]; + function setupDefaultMocks({ versionsLoading = false, filteredVersions = mockVersions, @@ -71,12 +91,15 @@ function setupDefaultMocks({ refetch: vi.fn(), }); - vi.mocked(useLanguages).mockReturnValue({ - languages: { data: [], next_page_token: null }, + vi.mocked(useLanguages).mockImplementation((params: Parameters[0]) => ({ + languages: { + data: params && 'country' in params ? [mockLanguages[0]!] : mockLanguages, + next_page_token: null, + }, loading: false, error: null, refetch: vi.fn(), - }); + })); vi.mocked(useLanguage).mockReturnValue({ language: { id: 'en', display_names: { en: 'English' }, language: 'English' }, @@ -219,4 +242,182 @@ describe('BibleVersionPicker', () => { }); }); }); + + describe('onVersionPickerPress override', () => { + it('calls onVersionPickerPress with { versionId, languageId } when Trigger is clicked', async () => { + const user = userEvent.setup(); + const onVersionPickerPress = vi.fn(); + + setupDefaultMocks(); + render( + + + , + ); + + await user.click(screen.getByRole('button')); + + expect(onVersionPickerPress).toHaveBeenCalledTimes(1); + expect(onVersionPickerPress).toHaveBeenCalledWith({ + versionId: 111, + languageId: 'en', + }); + }); + + it('does NOT render popover content when onVersionPickerPress is provided', () => { + setupDefaultMocks(); + render( + + + , + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument(); + }); + + it('uses controlled languageId in onVersionPickerPress payload', async () => { + const user = userEvent.setup(); + const onVersionPickerPress = vi.fn(); + + setupDefaultMocks(); + render( + + + , + ); + + await user.click(screen.getByRole('button')); + + expect(onVersionPickerPress).toHaveBeenCalledWith({ + versionId: 111, + languageId: 'es', + }); + }); + + it('does not call onVersionPickerPress when trigger click is prevented', async () => { + const user = userEvent.setup(); + const onVersionPickerPress = vi.fn(); + + setupDefaultMocks(); + render( + + event.preventDefault()} /> + , + ); + + await user.click(screen.getByRole('button')); + + expect(onVersionPickerPress).not.toHaveBeenCalled(); + }); + }); + + describe('standalone content', () => { + it('renders version content and calls onRequestClose after version selection', async () => { + const user = userEvent.setup(); + const onVersionChange = vi.fn(); + const onRequestClose = vi.fn(); + + setupDefaultMocks(); + render( + + + , + ); + + await user.click(screen.getByRole('listitem', { name: /new living translation/i })); + + expect(onVersionChange).toHaveBeenCalledWith(206); + expect(onRequestClose).toHaveBeenCalledTimes(1); + }); + + it('renders language content and calls onRequestClose after language selection', async () => { + const user = userEvent.setup(); + const onLanguageChange = vi.fn(); + const onRequestClose = vi.fn(); + + setupDefaultMocks(); + render( + + + , + ); + + expect(screen.queryByText('Select Language')).not.toBeInTheDocument(); + expect(screen.getByText('Suggested')).toBeInTheDocument(); + await user.click(screen.getByRole('listitem', { name: /english/i })); + + expect(onLanguageChange).toHaveBeenCalledWith('en'); + expect(onRequestClose).toHaveBeenCalledTimes(1); + }); + + it('language trigger calls custom click handler without preventing default behavior', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + + setupDefaultMocks(); + render( + + + , + ); + + await user.click(screen.getByRole('button', { name: /select language/i })); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('language trigger does not open default language view when click is prevented', async () => { + const user = userEvent.setup(); + + setupDefaultMocks(); + render( + + + event.preventDefault()} /> + + , + ); + + await user.click(screen.getByRole('button', { name: 'NIV' })); + await user.click(screen.getAllByRole('button', { name: /select language/i })[0]!); + + expect(screen.queryByText('All Languages')).not.toBeInTheDocument(); + }); + + it('open=false clears version search for pre-warmed standalone content', async () => { + const user = userEvent.setup(); + + setupDefaultMocks(); + const { rerender } = render( + + + , + ); + + await user.type(screen.getByPlaceholderText('Search'), 'nlt'); + expect(screen.getByPlaceholderText('Search')).toHaveValue('nlt'); + + rerender( + + + , + ); + + expect(screen.getByPlaceholderText('Search')).toHaveValue(''); + }); + }); }); diff --git a/packages/ui/src/components/bible-version-picker.tsx b/packages/ui/src/components/bible-version-picker.tsx index fce4c227..2f2008ec 100644 --- a/packages/ui/src/components/bible-version-picker.tsx +++ b/packages/ui/src/components/bible-version-picker.tsx @@ -9,7 +9,10 @@ import { useVersions, } from '@youversion/platform-react-hooks'; import { + cloneElement, createContext, + isValidElement, + type MouseEvent, type ReactNode, useCallback, useContext, @@ -152,6 +155,7 @@ type BibleVersionPickerContextType = { isPopoverOpen: boolean; setIsPopoverOpen: (open: boolean) => void; versionsLoading: boolean; + onVersionPickerPress?: (data: BibleVersionPickerPressData) => void; }; const BibleVersionPickerContext = createContext(null); @@ -167,16 +171,44 @@ function useBibleVersionPickerContext() { export type RootProps = { versionId: number; onVersionChange?: (versionId: number) => void; + languageId?: string; + defaultLanguageId?: string; + onLanguageChange?: (languageId: string) => void; background?: 'light' | 'dark'; side?: 'top' | 'right' | 'bottom' | 'left'; + onVersionPickerPress?: (data: BibleVersionPickerPressData) => void; children?: ReactNode; }; +export type BibleVersionPickerPressData = { + versionId: number; + languageId: string; +}; + +export type BibleVersionPickerContentProps = { + open?: boolean; + onRequestClose?: () => void; +}; + +export type BibleLanguagePickerContentProps = { + open?: boolean; + onRequestClose?: () => void; +}; + +export type BibleVersionPickerLanguageTriggerProps = Omit< + React.ComponentProps, + 'children' +>; + function Root({ versionId: controlledVersionId, onVersionChange, + languageId: controlledLanguageId, + defaultLanguageId, + onLanguageChange, background, side = 'top', + onVersionPickerPress, children, }: RootProps) { const [versionId, setVersionIdState] = useControllableState({ @@ -188,9 +220,15 @@ function Root({ const providerTheme = useTheme(); const theme = background || providerTheme; - const [selectedLanguageId, setSelectedLanguageId] = useState( - (typeof navigator !== 'undefined' && navigator.languages[0]?.split('-')[0]) || 'en', - ); + const fallbackLanguageId = + defaultLanguageId || + (typeof navigator !== 'undefined' && navigator.languages[0]?.split('-')[0]) || + 'en'; + const [selectedLanguageId, setSelectedLanguageId] = useControllableState({ + prop: controlledLanguageId, + defaultProp: fallbackLanguageId, + onChange: onLanguageChange, + }); const [searchQuery, setSearchQuery] = useState(''); const [isLanguagesOpen, setIsLanguagesOpen] = useState(false); const [recentVersions, setRecentVersions] = useState(getRecentVersions); @@ -314,8 +352,17 @@ function Root({ isPopoverOpen, setIsPopoverOpen, versionsLoading, + onVersionPickerPress, }; + if (onVersionPickerPress) { + return ( + + {children} + + ); + } + return ( @@ -335,7 +382,8 @@ export type BibleVersionPickerTriggerProps = Omit< }; function Trigger({ asChild = true, children, ...props }: BibleVersionPickerTriggerProps) { - const { versionId, background } = useBibleVersionPickerContext(); + const { versionId, selectedLanguageId, background, onVersionPickerPress } = + useBibleVersionPickerContext(); const { version, loading } = useVersion(versionId); const content = @@ -347,6 +395,30 @@ function Trigger({ asChild = true, children, ...props }: BibleVersionPickerTrigg ); + const handlePress = (event: MouseEvent) => { + props.onClick?.(event); + if (!event.defaultPrevented) { + onVersionPickerPress?.({ versionId, languageId: selectedLanguageId }); + } + }; + + if (onVersionPickerPress) { + if (asChild && isValidElement>(content)) { + return cloneElement(content, { + 'data-yv-sdk': true, + 'data-yv-theme': background, + ...props, + onClick: handlePress, + }); + } + + return ( + + ); + } + return ( {content} @@ -354,29 +426,98 @@ function Trigger({ asChild = true, children, ...props }: BibleVersionPickerTrigg ); } -function Content() { +/** + * @internal Advanced composition surface for SDK integrations. + * + * `onClick` is a DOM-local event handler. Expo DOM wrappers should expose a + * top-level async native action prop and call it from an internal `onClick` + * adapter that calls `event.preventDefault()` instead of passing native + * callbacks through as React event handlers. + */ +export function BibleVersionPickerLanguageTrigger({ + 'aria-label': ariaLabel = 'Select language', + className, + onClick, + size = 'sm', + variant = 'secondary', + ...props +}: BibleVersionPickerLanguageTriggerProps): React.ReactElement { + const { + filteredVersions, + 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); + + const handleClick = (event: MouseEvent) => { + onClick?.(event); + if (!event.defaultPrevented) { + setIsLanguagesOpen(true); + } + }; + + return ( + + ); +} + +function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {}) { const { searchQuery, setSearchQuery, filteredVersions, versionId, setVersionId, + recentVersions, + addRecentVersion, + versionsLoading, background, side, - setIsLanguagesOpen, isLanguagesOpen, - totalLanguages, - selectedLanguageId, - setSelectedLanguageId, - recentVersions, - addRecentVersion, - suggestedLanguages, - languages, + setIsLanguagesOpen, setIsPopoverOpen, - versionsLoading, + onVersionPickerPress, } = useBibleVersionPickerContext(); - const providerTheme = useTheme(); - const theme = background || providerTheme; + const wasOpenRef = useRef(open ?? false); const filteredRecentVersions = useMemo(() => { if (!searchQuery.trim()) return recentVersions; @@ -388,13 +529,6 @@ function Content() { 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); - - const handleSelectLanguage = (languageId: string) => { - setSelectedLanguageId(languageId); - setIsLanguagesOpen(false); - }; const handleSelectVersion = (version: BibleVersion | RecentVersion) => { setVersionId(version.id); @@ -404,102 +538,66 @@ function Content() { localized_abbreviation: version.localized_abbreviation, abbreviation: version.abbreviation, }); - setIsLanguagesOpen(false); - setIsPopoverOpen(false); + setSearchQuery(''); + onRequestClose?.(); }; - function LanguagePicker() { + useEffect(() => { + if (wasOpenRef.current && open === false) { + setSearchQuery(''); + setIsLanguagesOpen(false); + } + wasOpenRef.current = open ?? false; + }, [open, setIsLanguagesOpen, setSearchQuery]); + + if (!onVersionPickerPress && open === undefined && !onRequestClose) { return ( - + setIsPopoverOpen(false)} /> + +
+ setIsLanguagesOpen(false)} /> +
+ ); } return ( - } - theme={theme} - side={side} +
{/* Versions View */} -
-
- {/* Recent Versions */} - {filteredRecentVersions.length > 0 && ( - <> -

Recently Used Versions

- - {filteredRecentVersions.map((version) => ( - - - - ))} - - - )} - {/* All Versions */} - {filteredVersions.length > 0 ? ( - -

All Versions

- {filteredVersions.map((version: BibleVersion) => ( +
+ {/* Recent Versions */} + {filteredRecentVersions.length > 0 && ( + <> +

Recently Used Versions

+ + {filteredRecentVersions.map((version) => ( ))} - ) : versionsLoading ? ( -
- -
- ) : !filteredRecentVersions.length ? ( -
- No versions found -
- ) : null} -
+ + )} + {/* All Versions */} + {filteredVersions.length > 0 ? ( + +

All Versions

+ {filteredVersions.map((version: BibleVersion) => ( + + + + ))} +
+ ) : versionsLoading ? ( +
+ +
+ ) : !filteredRecentVersions.length ? ( +
+ No versions found +
+ ) : null} +
-
+
+
-
-
- - {/* Languages View */} -
-
- -

Select Language

-
+
+ +
+ ); +} - +
+ +

Select Language

+
+ + + ); +} + +/** + * @internal Advanced composition surface for SDK integrations. + * + * Expo DOM wrappers should compose this content inside the `'use dom'` file and + * expose only serializable props plus top-level async native actions to native. + */ +export function BibleLanguagePickerContent({ + open, + onRequestClose, +}: BibleLanguagePickerContentProps = {}): React.ReactElement { + const { + totalLanguages, + selectedLanguageId, + setSelectedLanguageId, + suggestedLanguages, + languages, + background, + } = useBibleVersionPickerContext(); + + const handleSelectLanguage = (languageId: string) => { + setSelectedLanguageId(languageId); + onRequestClose?.(); + }; + + return ( +
+ + + + Suggested + + + All ({totalLanguages}) + + + + + {suggestedLanguages.length > 0 ? ( + <> +

Regional

+ + {suggestedLanguages.map((suggestedLanguage) => ( + + - - ))} - - - ) : ( -

- No regional languages available -

- )} -
- - -

All Languages

- - {languages.map((language) => ( - + + {suggestedLanguage.display_names?.en} + + + {suggestedLanguage.display_names?.[suggestedLanguage.id]} + + + + + ))} + + + ) : ( +

+ No regional languages available +

+ )} +
+ + +

All Languages

+ + {languages.map((language) => ( + + - - ))} - -
-
-
-
+ + + {language.display_names?.en} + + + {language.display_names?.[language.id]} + + + + + ))} + + + + ); } diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index cdcd1491..e5f96097 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -9,8 +9,14 @@ export { export { BibleReader, type BibleReaderRootProps } from './bible-reader'; export { BibleVersionPicker, + BibleLanguagePickerContent, + BibleVersionPickerLanguageTrigger, type BibleVersionPickerRootProps, type BibleVersionPickerTriggerProps, + type BibleVersionPickerPressData, + type BibleVersionPickerContentProps, + type BibleLanguagePickerContentProps, + type BibleVersionPickerLanguageTriggerProps, } from './bible-version-picker'; export { YouVersionAuthButton, type YouVersionAuthButtonProps } from './YouVersionAuthButton'; export { VerseOfTheDay, type VerseOfTheDayProps } from './verse-of-the-day';