Skip to content
5 changes: 5 additions & 0 deletions .changeset/bible-version-picker-language-search.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion packages/ui/src/components/bible-card.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
41 changes: 37 additions & 4 deletions packages/ui/src/components/bible-version-picker.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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');

Expand All @@ -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('');
},
};
Expand Down Expand Up @@ -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
Expand Down
219 changes: 205 additions & 14 deletions packages/ui/src/components/bible-version-picker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<typeof useVersions>;
}

return {
versions: versionsLoading ? null : { data: mockVersions, next_page_token: null },
loading: versionsLoading,
error: null,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useVersions>;
});

vi.mocked(useVersion).mockReturnValue({
Expand All @@ -92,11 +121,13 @@ function setupDefaultMocks({
});

vi.mocked(useLanguages).mockImplementation((params: Parameters<typeof useLanguages>[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(),
}));
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -434,16 +487,154 @@ describe('BibleVersionPicker', () => {
</BibleVersionPicker.Root>,
);

await user.type(screen.getByPlaceholderText('Search'), 'nlt');
expect(screen.getByPlaceholderText('Search')).toHaveValue('nlt');
await user.type(getVersionSearchInput(), 'nlt');
expect(getVersionSearchInput()).toHaveValue('nlt');

rerender(
<BibleVersionPicker.Root versionId={111} onVersionPickerPress={vi.fn()}>
<BibleVersionPicker.Content open={false} />
</BibleVersionPicker.Root>,
);

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(
<BibleVersionPicker.Root {...rootProps}>
<BibleLanguagePickerContent open />
</BibleVersionPicker.Root>,
);

await user.type(getLanguageSearchInput(), 'span');
expect(getLanguageSearchInput()).toHaveValue('span');
expect(screen.queryByRole('tab', { name: /suggested/i })).not.toBeInTheDocument();

rerender(
<BibleVersionPicker.Root {...rootProps}>
<BibleLanguagePickerContent open={false} />
</BibleVersionPicker.Root>,
);

expect(getLanguageSearchInput()).toHaveValue('');

rerender(
<BibleVersionPicker.Root {...rootProps}>
<BibleLanguagePickerContent open />
</BibleVersionPicker.Root>,
);

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(
<BibleVersionPicker.Root versionId={111} onLanguageChange={onLanguageChange}>
<BibleVersionPicker.Trigger>
<button type="button">Open</button>
</BibleVersionPicker.Trigger>
<BibleVersionPicker.Content />
</BibleVersionPicker.Root>,
);

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();
});
});
});
Loading
Loading