diff --git a/.changeset/bible-version-picker-language-search.md b/.changeset/bible-version-picker-language-search.md new file mode 100644 index 00000000..2dc8b73e --- /dev/null +++ b/.changeset/bible-version-picker-language-search.md @@ -0,0 +1,5 @@ +--- +"@youversion/platform-react-ui": minor +--- + +Add language search to `BibleVersionPicker`. The language panel now includes a bottom search input that globally filters available languages, shows a no-results empty state, and clears when the panel or popover closes. diff --git a/packages/ui/src/components/bible-card.stories.tsx b/packages/ui/src/components/bible-card.stories.tsx index 5e5ea9b0..52649147 100644 --- a/packages/ui/src/components/bible-card.stories.tsx +++ b/packages/ui/src/components/bible-card.stories.tsx @@ -137,7 +137,9 @@ export const WithVersionPicker: Story = { }); // Search for Amplified Bible - const searchInput = within(await screen.findByRole('dialog')).getByPlaceholderText('Search'); + const searchInput = within(await screen.findByRole('dialog')).getByRole('textbox', { + name: /search bible versions/i, + }); await userEvent.type(searchInput, 'amplified bible'); await waitFor(async () => { diff --git a/packages/ui/src/components/bible-version-picker.stories.tsx b/packages/ui/src/components/bible-version-picker.stories.tsx index f1f1d50a..df29d9b2 100644 --- a/packages/ui/src/components/bible-version-picker.stories.tsx +++ b/packages/ui/src/components/bible-version-picker.stories.tsx @@ -312,6 +312,39 @@ export const SuggestedLanguagesOrder: Story = { }, }; +export const LanguageSearch: Story = { + args: { + versionId: 111, + }, + tags: ['integration'], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const trigger = await canvas.findByRole('button', { name: /NIV/i }, { timeout: 10_000 }); + await userEvent.click(trigger); + + const languageButton = await screen.findByRole('button', { name: /select language/i }); + await userEvent.click(languageButton); + + const languageSearchInput = screen.getByRole('textbox', { name: /search languages/i }); + await userEvent.type(languageSearchInput, 'Korean', { delay: 50 }); + + await expect(screen.queryByRole('tab', { name: /suggested/i })).not.toBeInTheDocument(); + await expect(screen.queryByRole('tab', { name: /all/i })).not.toBeInTheDocument(); + + const results = await screen.findByTestId('language-search-results'); + await expect(within(results).getAllByRole('listitem')).toHaveLength(1); + await expect(within(results).getByRole('listitem', { name: /korean/i })).toBeInTheDocument(); + + await userEvent.clear(languageSearchInput); + await userEvent.type(languageSearchInput, 'Koreanea', { delay: 50 }); + + await expect( + screen.getByText("We're sorry, there are no results for this search."), + ).toBeInTheDocument(); + }, +}; + export const InteractiveVersionSearch: Story = { args: { versionId: 111, @@ -325,7 +358,7 @@ export const InteractiveVersionSearch: Story = { await userEvent.click(trigger); // Type in search - const searchInput = screen.getByPlaceholderText('Search'); + const searchInput = screen.getByRole('textbox', { name: /search bible versions/i }); await userEvent.type(searchInput, 'NIV', { delay: 50 }); // Verify search is working (versions should be filtered) @@ -424,7 +457,7 @@ export const SearchResetsAfterSelection: Story = { await userEvent.click(trigger); // Type in search - const searchInput = screen.getByPlaceholderText('Search'); + const searchInput = screen.getByRole('textbox', { name: /search bible versions/i }); await userEvent.type(searchInput, 'Amplified', { delay: 50 }); await expect(searchInput).toHaveValue('Amplified'); @@ -437,7 +470,7 @@ export const SearchResetsAfterSelection: Story = { await userEvent.click(updatedTrigger); // Verify search input is cleared - const resetSearchInput = screen.getByPlaceholderText('Search'); + const resetSearchInput = screen.getByRole('textbox', { name: /search bible versions/i }); await expect(resetSearchInput).toHaveValue(''); }, }; @@ -471,7 +504,7 @@ export const RecentVersionsSearchFilter: Story = { ).toBeInTheDocument(); // Search for "ASV" - const searchInput = screen.getByPlaceholderText('Search'); + const searchInput = screen.getByRole('textbox', { name: /search bible versions/i }); await userEvent.type(searchInput, 'ASV', { delay: 50 }); // Verify only ASV appears in recent versions after filtering diff --git a/packages/ui/src/components/bible-version-picker.test.tsx b/packages/ui/src/components/bible-version-picker.test.tsx index 37d4eae9..395c02d3 100644 --- a/packages/ui/src/components/bible-version-picker.test.tsx +++ b/packages/ui/src/components/bible-version-picker.test.tsx @@ -3,7 +3,7 @@ */ /* eslint-disable @typescript-eslint/no-empty-function */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; // ResizeObserver is used by VersionAbbreviationIcon and @floating-ui/dom (Radix Popover) @@ -68,20 +68,49 @@ const mockLanguages: Language[] = [ display_names: { en: 'Spanish', es: 'Español' }, speaking_population: 500000000, }, + { + id: 'ko', + language: 'Korean', + display_names: { en: 'Korean', ko: '한국어' }, + speaking_population: 80000000, + }, ]; function setupDefaultMocks({ versionsLoading = false, + languagesLoading = false, + versionsLanguageInfoLoading = false, filteredVersions = mockVersions, }: { versionsLoading?: boolean; + languagesLoading?: boolean; + versionsLanguageInfoLoading?: boolean; filteredVersions?: BibleVersion[]; } = {}) { - vi.mocked(useVersions).mockReturnValue({ - versions: versionsLoading ? null : { data: mockVersions, next_page_token: null }, - loading: versionsLoading, - error: null, - refetch: vi.fn(), + vi.mocked(useVersions).mockImplementation((languageId) => { + if (languageId === '*') { + return { + versions: versionsLanguageInfoLoading + ? null + : { + data: mockLanguages.map((language) => ({ + id: language.id === 'en' ? 111 : language.id === 'es' ? 222 : 333, + language_tag: language.id, + })), + next_page_token: null, + }, + loading: versionsLanguageInfoLoading, + error: null, + refetch: vi.fn(), + } as unknown as ReturnType; + } + + return { + versions: versionsLoading ? null : { data: mockVersions, next_page_token: null }, + loading: versionsLoading, + error: null, + refetch: vi.fn(), + } as unknown as ReturnType; }); vi.mocked(useVersion).mockReturnValue({ @@ -92,11 +121,13 @@ function setupDefaultMocks({ }); vi.mocked(useLanguages).mockImplementation((params: Parameters[0]) => ({ - languages: { - data: params && 'country' in params ? [mockLanguages[0]!] : mockLanguages, - next_page_token: null, - }, - loading: false, + languages: languagesLoading + ? null + : { + data: params && 'country' in params ? [mockLanguages[0]!] : mockLanguages, + next_page_token: null, + }, + loading: languagesLoading, error: null, refetch: vi.fn(), })); @@ -129,9 +160,31 @@ async function openPicker() { await userEvent.click(trigger); } +async function openLanguagePanel() { + await openPicker(); + await userEvent.click(screen.getByRole('button', { name: /select language/i })); +} + +function getLanguageSearchInput() { + return screen.getByRole('textbox', { name: /search languages/i }); +} + +function getVersionSearchInput() { + return screen.getByRole('textbox', { name: /search bible versions/i }); +} + describe('BibleVersionPicker', () => { beforeEach(() => { vi.resetAllMocks(); + Object.defineProperty(window, 'localStorage', { + value: { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }, + writable: true, + }); }); describe('loading state', () => { @@ -434,8 +487,8 @@ describe('BibleVersionPicker', () => { , ); - await user.type(screen.getByPlaceholderText('Search'), 'nlt'); - expect(screen.getByPlaceholderText('Search')).toHaveValue('nlt'); + await user.type(getVersionSearchInput(), 'nlt'); + expect(getVersionSearchInput()).toHaveValue('nlt'); rerender( @@ -443,7 +496,145 @@ describe('BibleVersionPicker', () => { , ); - expect(screen.getByPlaceholderText('Search')).toHaveValue(''); + expect(getVersionSearchInput()).toHaveValue(''); + }); + + it('open=false clears language search for pre-warmed standalone content', async () => { + const user = userEvent.setup(); + + setupDefaultMocks(); + const rootProps = { versionId: 111, onVersionPickerPress: vi.fn() }; + const { rerender } = render( + + + , + ); + + await user.type(getLanguageSearchInput(), 'span'); + expect(getLanguageSearchInput()).toHaveValue('span'); + expect(screen.queryByRole('tab', { name: /suggested/i })).not.toBeInTheDocument(); + + rerender( + + + , + ); + + expect(getLanguageSearchInput()).toHaveValue(''); + + rerender( + + + , + ); + + expect(screen.getByRole('tab', { name: /suggested/i })).toBeInTheDocument(); + }); + }); + + describe('language search', () => { + it('filters languages globally when searching', async () => { + const user = userEvent.setup(); + + setupDefaultMocks(); + renderPicker(); + await openLanguagePanel(); + + await user.type(getLanguageSearchInput(), 'span'); + + expect(screen.queryByRole('tab', { name: /suggested/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: /all/i })).not.toBeInTheDocument(); + + const results = screen.getByTestId('language-search-results'); + expect(within(results).getAllByRole('listitem')).toHaveLength(1); + expect(within(results).getByRole('listitem', { name: /spanish/i })).toBeInTheDocument(); + }); + + it('shows empty state when no languages match the search', async () => { + const user = userEvent.setup(); + + setupDefaultMocks(); + renderPicker(); + await openLanguagePanel(); + + await user.type(getLanguageSearchInput(), 'Koreanea'); + + expect( + screen.getByText("We're sorry, there are no results for this search."), + ).toBeInTheDocument(); + expect(screen.queryByTestId('language-search-results')).not.toBeInTheDocument(); + }); + + it('shows loading state instead of empty results while languages are loading', async () => { + const user = userEvent.setup(); + + setupDefaultMocks({ languagesLoading: true }); + renderPicker(); + await openLanguagePanel(); + + await user.type(getLanguageSearchInput(), 'span'); + + expect( + screen.queryByText("We're sorry, there are no results for this search."), + ).not.toBeInTheDocument(); + expect( + screen + .getByRole('textbox', { name: /search languages/i }) + .closest('[data-yv-sdk]') + ?.querySelector('.yv\\:animate-spin'), + ).toBeInTheDocument(); + }); + + it('clears language search when navigating back to bible versions', async () => { + const user = userEvent.setup(); + + setupDefaultMocks(); + renderPicker(); + await openLanguagePanel(); + + await user.type(getLanguageSearchInput(), 'korean'); + expect(getLanguageSearchInput()).toHaveValue('korean'); + + await user.click(screen.getByRole('button', { name: /back to bible versions/i })); + await user.click(screen.getByRole('button', { name: /select language/i })); + + expect(getLanguageSearchInput()).toHaveValue(''); + }); + + it('preserves the selected tab after clearing language search', async () => { + const user = userEvent.setup(); + + setupDefaultMocks(); + renderPicker(); + await openLanguagePanel(); + + await user.click(screen.getByRole('tab', { name: /all/i })); + await user.type(getLanguageSearchInput(), 'span'); + await user.clear(getLanguageSearchInput()); + + expect(screen.getByRole('tab', { name: /all/i })).toHaveAttribute('aria-selected', 'true'); + }); + + it('calls onLanguageChange and clears search after selecting a filtered language', async () => { + const user = userEvent.setup(); + const onLanguageChange = vi.fn(); + + setupDefaultMocks(); + render( + + + + + + , + ); + + await openLanguagePanel(); + await user.type(getLanguageSearchInput(), 'korean'); + await user.click(screen.getByRole('listitem', { name: /korean/i })); + + expect(onLanguageChange).toHaveBeenCalledWith('ko'); + expect(screen.getByRole('heading', { name: /bible versions/i })).toBeInTheDocument(); }); }); }); diff --git a/packages/ui/src/components/bible-version-picker.tsx b/packages/ui/src/components/bible-version-picker.tsx index 8e0160c9..4afdf1ed 100644 --- a/packages/ui/src/components/bible-version-picker.tsx +++ b/packages/ui/src/components/bible-version-picker.tsx @@ -56,6 +56,24 @@ function saveRecentVersions(versions: RecentVersion[]): void { localStorage.setItem(RECENT_VERSIONS_KEY, JSON.stringify(versions)); } +type LanguageListItem = Pick & { + language?: string; +}; + +function filterLanguagesBySearch(languages: LanguageListItem[], query: string): LanguageListItem[] { + const trimmedQuery = query.trim().toLowerCase(); + if (!trimmedQuery) return languages; + + return languages.filter((language) => { + if (language.id.toLowerCase().includes(trimmedQuery)) return true; + if (language.language?.toLowerCase().includes(trimmedQuery)) return true; + + return Object.values(language.display_names ?? {}).some((name) => + name.toLowerCase().includes(trimmedQuery), + ); + }); +} + // Displays a version abbreviation (e.g., "NIV", "KJV2") centered within a fixed-size icon. // Dynamically scales the font size to fit the text within the container with padding. function VersionAbbreviationIcon({ text }: { text: string }) { @@ -148,6 +166,12 @@ type BibleVersionPickerContextType = { setSelectedLanguageId: (id: string) => void; searchQuery: string; setSearchQuery: (query: string) => void; + languageSearchQuery: string; + setLanguageSearchQuery: (query: string) => void; + filteredLanguages: LanguageListItem[]; + isLoadingLanguages: boolean; + closeLanguagePanel: () => void; + resetLanguageSearch: () => void; suggestedLanguages: Pick[]; filteredVersions: BibleVersion[]; filteredRecentVersions: RecentVersion[]; @@ -233,21 +257,32 @@ function Root({ onChange: onLanguageChange, }); const [searchQuery, setSearchQuery] = useState(''); + const [languageSearchQuery, setLanguageSearchQuery] = useState(''); const [isLanguagesOpen, setIsLanguagesOpen] = useState(false); const [recentVersions, setRecentVersions] = useState(getRecentVersions); const [isPopoverOpenRaw, setIsPopoverOpenRaw] = useState(false); const isPopoverOpen = onVersionPickerPress ? false : isPopoverOpenRaw; + const resetLanguageSearch = useCallback(() => { + setLanguageSearchQuery(''); + }, []); + + const closeLanguagePanel = useCallback(() => { + setLanguageSearchQuery(''); + setIsLanguagesOpen(false); + }, []); + const setIsPopoverOpen = useCallback( (open: boolean) => { if (onVersionPickerPress) return; setIsPopoverOpenRaw(open); if (!open) { setSearchQuery(''); + setLanguageSearchQuery(''); setIsLanguagesOpen(false); } }, - [setSearchQuery, onVersionPickerPress], + [onVersionPickerPress], ); const addRecentVersion = useCallback((version: RecentVersion) => { @@ -259,16 +294,21 @@ function Root({ }); }, []); - const { languages } = useLanguages({ + const { languages, loading: languagesLoading } = useLanguages({ fields: ['id', 'display_names', 'speaking_population'], page_size: '*', }); const { versions, loading: versionsLoading } = useVersions(selectedLanguageId); - const { versions: versionsLanguageInfo } = useVersions('*', undefined, { - fields: ['id', 'language_tag'], - page_size: '*', - }); + const { versions: versionsLanguageInfo, loading: versionsLanguageInfoLoading } = useVersions( + '*', + undefined, + { + fields: ['id', 'language_tag'], + page_size: '*', + }, + ); + const isLoadingLanguages = languagesLoading || versionsLanguageInfoLoading; const filteredVersions = useFilteredVersions( versions?.data || [], searchQuery, @@ -348,6 +388,8 @@ function Root({ return [...new Map(combined.map((lang) => [lang.id, lang])).values()]; }, [uniqueLanguages, countryLanguages]); + const filteredLanguages = filterLanguagesBySearch(uniqueLanguages, languageSearchQuery); + const contextValue: BibleVersionPickerContextType = { versionId, setVersionId: setVersionIdState, @@ -359,6 +401,12 @@ function Root({ setSelectedLanguageId, searchQuery, setSearchQuery, + languageSearchQuery, + setLanguageSearchQuery, + filteredLanguages, + isLoadingLanguages, + closeLanguagePanel, + resetLanguageSearch, suggestedLanguages, filteredVersions, filteredRecentVersions, @@ -524,6 +572,8 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {}) isLanguagesOpen, setIsLanguagesOpen, setIsPopoverOpen, + closeLanguagePanel, + resetLanguageSearch, onVersionPickerPress, } = useBibleVersionPickerContext(); const wasOpenRef = useRef(open ?? false); @@ -543,10 +593,11 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {}) useEffect(() => { if (wasOpenRef.current && open === false) { setSearchQuery(''); + resetLanguageSearch(); setIsLanguagesOpen(false); } wasOpenRef.current = open ?? false; - }, [open, setIsLanguagesOpen, setSearchQuery]); + }, [open, resetLanguageSearch, setIsLanguagesOpen, setSearchQuery]); if (onVersionPickerPress && open === undefined && !onRequestClose) { return null; @@ -561,7 +612,7 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {}) headerLeading={ isLanguagesOpen ? ( + + ); +} + /** * @internal Advanced composition surface for SDK integrations. * @@ -749,11 +840,31 @@ export function BibleLanguagePickerContent({ setSelectedLanguageId, suggestedLanguages, languages, + filteredLanguages, + languageSearchQuery, + setLanguageSearchQuery, + resetLanguageSearch, + isLoadingLanguages, background, } = useBibleVersionPickerContext(); + const [languageTab, setLanguageTab] = useState('suggested'); + const wasOpenRef = useRef(open ?? false); + + // RN standalone/Expo integrations keep this content mounted and toggle `open`; + // reset search so reopening starts on suggested/all tabs, not stale results. + useEffect(() => { + if (wasOpenRef.current && open === false) { + resetLanguageSearch(); + } + wasOpenRef.current = open ?? false; + }, [open, resetLanguageSearch]); + + const isSearching = languageSearchQuery.trim().length > 0; + const handleSelectLanguage = (languageId: string) => { setSelectedLanguageId(languageId); + resetLanguageSearch(); onRequestClose?.(); }; @@ -764,101 +875,103 @@ export function BibleLanguagePickerContent({ data-yv-theme={background} data-open={open} > - - - - {t('suggestedTab')} - - - {t('allLanguagesTab', { count: totalLanguages })} - - - - - {suggestedLanguages.length > 0 ? ( - <> -

- {t('regionalHeading')} -

- - {suggestedLanguages.map((suggestedLanguage) => ( - - - - ))} - - + {isSearching ? ( +
+ {isLoadingLanguages ? ( +
+ +
+ ) : filteredLanguages.length > 0 ? ( + + {filteredLanguages.map((language) => ( + + ))} + ) : ( -

- {t('noRegionalLanguagesAvailable')} +

+ {t('noLanguageSearchResults')}

)} - - - -

- {t('allLanguagesHeading')} -

- - {languages.map((language) => ( - - - - ))} - -
- +
+ ) : ( + + + + {t('suggestedTab')} + + + {t('allLanguagesTab', { count: totalLanguages })} + + + + + {suggestedLanguages.length > 0 ? ( + <> +

+ {t('regionalHeading')} +

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

+ {t('noRegionalLanguagesAvailable')} +

+ )} +
+ + +

+ {t('allLanguagesHeading')} +

+ + {languages.map((language) => ( + + ))} + +
+
+ )} + +
+
+ + setLanguageSearchQuery(e.target.value)} + /> + + + + +
+
); } diff --git a/packages/ui/src/i18n/locales/en.json b/packages/ui/src/i18n/locales/en.json index d2a4b49b..1d63a50f 100644 --- a/packages/ui/src/i18n/locales/en.json +++ b/packages/ui/src/i18n/locales/en.json @@ -43,6 +43,9 @@ "noChaptersAvailable": "No chapters available", "noBibleSearchResults": "We're sorry, there are no Bible results for this search.", "noVersionsFound": "No versions found", + "noLanguageSearchResults": "We're sorry, there are no results for this search.", + "languageSearchAriaLabel": "Search languages", + "versionSearchAriaLabel": "Search Bible versions", "noRegionalLanguagesAvailable": "No regional languages available", "suggestedTab": "Suggested", "allLanguagesTab": "All ({{count}})",