diff --git a/.changeset/language-picker-v2-card.md b/.changeset/language-picker-v2-card.md new file mode 100644 index 000000000..85cdb6758 --- /dev/null +++ b/.changeset/language-picker-v2-card.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': minor +--- + +Open Bible languages from a row under version search, and search languages on their own screen. Keep tablet picker cards at 400 by 600 pixels, capped to fit the viewport. Show the selected language and the number of rows in its version section, excluding recent versions. Add a forward chevron to version rows. Keep language navigation in the SDK popover so standalone content retains host-owned controls. Expose the language count to screen readers. diff --git a/.changeset/preserve-webkit-on-css-watch.md b/.changeset/preserve-webkit-on-css-watch.md new file mode 100644 index 000000000..0256df8cc --- /dev/null +++ b/.changeset/preserve-webkit-on-css-watch.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': patch +--- + +Re-apply the host revert-layer `-webkit-appearance` fix when Tailwind watch rewrites CSS during `dev` and Storybook. diff --git a/.size-limit.js b/.size-limit.js index 4a41bc957..02cf64291 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -62,6 +62,8 @@ export default [ { name: 'ui / tailwind.css', path: 'packages/ui/dist/tailwind.css', - limit: '9.5 KB', + // Language picker wrap/truncate (whitespace-normal, max-w-full, w-auto). + // Measured brotlied size 9502 B; set to measured + ~10% per CONTRIBUTING. + limit: '10.5 KB', }, ]; diff --git a/packages/ui/package.json b/packages/ui/package.json index 60804152a..d5b26e014 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -39,14 +39,14 @@ "generate:i18n": "node scripts/generate-i18n-resources.mjs", "check:entries": "node scripts/check-public-entries.mjs", "check:exports": "node scripts/check-public-exports.mjs", - "build": "rm -rf dist && pnpm generate:i18n && pnpm check:entries && pnpm build:css && pnpm build:js && pnpm check:exports && pnpm verify:styles && pnpm build:types", - "dev": "pnpm build:css && concurrently \"node scripts/generate-i18n-resources.mjs --watch\" \"tailwindcss -i src/styles/chrome.css -o dist/chrome.css --watch\" \"tailwindcss -i src/styles/global.css -o dist/tailwind.css --watch\" \"tailwindcss -i src/styles/reader.css -o dist/bible-reader.css --watch\" \"node scripts/write-public-styles.js --watch\" \"NODE_ENV=development tsup --watch\"", + "build": "rm -rf dist && pnpm generate:i18n && pnpm check:entries && pnpm build:css && pnpm build:js && pnpm check:exports && node scripts/preserve-host-revert-layer.js && pnpm verify:styles && pnpm build:types", + "dev": "pnpm build:css && concurrently \"node scripts/generate-i18n-resources.mjs --watch\" \"tailwindcss -i src/styles/chrome.css -o dist/chrome.css --watch\" \"tailwindcss -i src/styles/global.css -o dist/tailwind.css --watch\" \"tailwindcss -i src/styles/reader.css -o dist/bible-reader.css --watch\" \"node scripts/watch-preserve-host-revert-layer.js\" \"node scripts/write-public-styles.js --watch\" \"NODE_ENV=development tsup --watch\"", "test": "pnpm generate:i18n && vitest run --project unit", "test:watch": "pnpm generate:i18n && vitest --project unit", "test:coverage": "pnpm generate:i18n && vitest run --project unit --coverage", "typecheck": "pnpm generate:i18n && tsc --noEmit", "prepublishOnly": "YVP_PUBLISH_BUILD=true pnpm --filter @youversion/platform-core build && pnpm build && node ../../scripts/check-sdk-version-stamp.mjs core", - "storybook": "pnpm build:css && pnpm build:storybook-css && concurrently \"node scripts/generate-i18n-resources.mjs --watch\" \"tailwindcss -i src/styles/chrome.css -o dist/chrome.css --watch\" \"tailwindcss -i src/styles/global.css -o dist/tailwind.css --watch\" \"tailwindcss -i src/styles/reader.css -o dist/bible-reader.css --watch\" \"node scripts/write-public-styles.js --watch\" \"storybook dev -p 6006\"", + "storybook": "pnpm build:css && pnpm build:storybook-css && concurrently \"node scripts/generate-i18n-resources.mjs --watch\" \"tailwindcss -i src/styles/chrome.css -o dist/chrome.css --watch\" \"tailwindcss -i src/styles/global.css -o dist/tailwind.css --watch\" \"tailwindcss -i src/styles/reader.css -o dist/bible-reader.css --watch\" \"node scripts/watch-preserve-host-revert-layer.js\" \"node scripts/write-public-styles.js --watch\" \"storybook dev -p 6006\"", "build-storybook": "pnpm generate:i18n && pnpm build:css && pnpm build:storybook-css && storybook build", "test:integration": "vitest run" }, diff --git a/packages/ui/scripts/check-public-entries.mjs b/packages/ui/scripts/check-public-entries.mjs index 3d808e3df..88bdf9e35 100644 --- a/packages/ui/scripts/check-public-entries.mjs +++ b/packages/ui/scripts/check-public-entries.mjs @@ -34,8 +34,13 @@ function barrelModules() { function resolveBarrelEntry(specifier) { const rel = specifier.replace(/^\.\//, ''); - for (const ext of ['.tsx', '.ts']) { - const entry = `src/components/${rel}${ext}`; + const candidates = [ + `src/components/${rel}.tsx`, + `src/components/${rel}.ts`, + `src/components/${rel}/index.tsx`, + `src/components/${rel}/index.ts`, + ]; + for (const entry of candidates) { if (existsSync(join(uiRoot, entry))) { return entry; } diff --git a/packages/ui/scripts/verify-styles.js b/packages/ui/scripts/verify-styles.js index 5c42f8605..cc9c99b0d 100644 --- a/packages/ui/scripts/verify-styles.js +++ b/packages/ui/scripts/verify-styles.js @@ -1,6 +1,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; import { stripLayerBlocks } from './strip-layer-blocks.js'; const uiRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -9,6 +10,16 @@ const errors = []; const packageJson = JSON.parse(readFileSync(resolve(uiRoot, 'package.json'), 'utf8')); const publicCssExport = packageJson.exports?.['./styles.css']; +// Tailwind --watch (and a concurrent build) can rewrite dist CSS after +// build:css already ran preserve-host-revert-layer. Re-apply before gating. +const scriptsDir = dirname(fileURLToPath(import.meta.url)); +spawnSync(process.execPath, [resolve(scriptsDir, 'preserve-host-revert-layer.js')], { + stdio: 'inherit', +}); +spawnSync(process.execPath, [resolve(scriptsDir, 'write-public-styles.js')], { + stdio: 'inherit', +}); + const chromeCssPath = resolve(dist, 'chrome.css'); const chromeCss = existsSync(chromeCssPath) ? readFileSync(chromeCssPath, 'utf-8') : ''; if (!chromeCss.trim()) { @@ -60,6 +71,13 @@ if (!publicCss.trim()) { if (!publicCss.includes('--yv-reader-font-size')) { errors.push('dist/styles.css missing reader typography (manual import contract)'); } + const unlayeredPublic = stripLayerBlocks(publicCss); + if (!unlayeredPublic.includes('revert-layer')) { + errors.push('dist/styles.css missing unlayered A2 revert-layer rule'); + } + if (!unlayeredPublic.includes('-webkit-appearance:revert-layer')) { + errors.push('dist/styles.css missing -webkit-appearance:revert-layer on the unlayered A2 rule'); + } } const jsPath = resolve(dist, 'index.js'); @@ -104,6 +122,9 @@ if (existsSync(dist)) { if (!js.includes('revert-layer')) { errors.push('dist/index.js missing A2 revert-layer isolation rule'); } + if (!js.includes('-webkit-appearance:revert-layer')) { + errors.push('dist/index.js missing -webkit-appearance:revert-layer on the A2 rule'); + } } if (errors.length > 0) { diff --git a/packages/ui/scripts/watch-preserve-host-revert-layer.js b/packages/ui/scripts/watch-preserve-host-revert-layer.js new file mode 100644 index 000000000..8ef3c8e00 --- /dev/null +++ b/packages/ui/scripts/watch-preserve-host-revert-layer.js @@ -0,0 +1,24 @@ +// Re-run preserve-host-revert-layer after Tailwind watch rewrites dist CSS. +// lightningcss drops `-webkit-appearance` on minify; the one-shot build:css +// pipeline puts it back, but `tailwindcss --watch` does not. +import { spawnSync } from 'node:child_process'; +import { watch } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptsDir = dirname(fileURLToPath(import.meta.url)); +const dist = resolve(scriptsDir, '..', 'dist'); +const preserve = resolve(scriptsDir, 'preserve-host-revert-layer.js'); + +const targets = new Set(['chrome.css', 'tailwind.css']); +let timer = null; + +function runPreserve() { + spawnSync(process.execPath, [preserve], { stdio: 'inherit' }); +} + +watch(dist, { persistent: true }, (_event, filename) => { + if (!filename || !targets.has(filename)) return; + clearTimeout(timer); + timer = setTimeout(runPreserve, 50); +}); diff --git a/packages/ui/src/components/bible-version-picker.stories.tsx b/packages/ui/src/components/bible-version-picker.stories.tsx index 3bc871579..5c8218a46 100644 --- a/packages/ui/src/components/bible-version-picker.stories.tsx +++ b/packages/ui/src/components/bible-version-picker.stories.tsx @@ -7,6 +7,8 @@ import { BookOpenIcon } from './icons/book-open'; import { Button } from './ui/button'; import { RECENT_VERSIONS_KEY } from './bible-version-picker'; import i18n from '@/i18n'; +import mockLanguages from '@/test/mock-data/languages.json'; +import { globalHandlers } from '@/test/mocks/handlers'; type StoredRecentVersion = { id: number; @@ -91,6 +93,129 @@ export const Default: Story = { }, }; +const tabletJourney: Story = { + args: { versionId: 111 }, + parameters: { + viewport: { + options: { + tabletPortrait: { name: 'Tablet portrait', styles: { width: '834px', height: '1194px' } }, + tabletLandscape: { name: 'Tablet landscape', styles: { width: '1180px', height: '820px' } }, + narrow: { name: 'Narrow', styles: { width: '375px', height: '812px' } }, + short: { name: 'Short', styles: { width: '820px', height: '400px' } }, + }, + }, + msw: { + handlers: [ + http.get('*/v1/languages', ({ request }) => + HttpResponse.json( + new URL(request.url).searchParams.has('country') + ? { + data: mockLanguages.data.filter((language) => language.id === 'en'), + next_page_token: null, + } + : mockLanguages, + ), + ), + http.get('*/v1/languages/es', () => + HttpResponse.json({ + id: 'es', + language: 'es', + display_names: { es: 'Español (América Latina)', en: 'Spanish' }, + }), + ), + ...globalHandlers, + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = await canvas.findByRole('button', { name: /NIV/i }); + await userEvent.click(trigger); + const dialog = await screen.findByRole('dialog'); + await waitFor(async () => { + await expect(screen.getByRole('button', { name: /^Language English 9$/ })).toBeVisible(); + const bounds = dialog.getBoundingClientRect(); + await expect( + Math.abs(bounds.width - Math.min(400, window.innerWidth - 32)), + ).toBeLessThanOrEqual(1); + // Matches max-h: min(600px, 100svh - 32px, --radix-popover-content-available-height). + const radixAvailable = Number.parseFloat( + getComputedStyle(dialog).getPropertyValue('--radix-popover-content-available-height'), + ); + const expectedHeight = Math.min( + 600, + window.innerHeight - 32, + Number.isFinite(radixAvailable) ? radixAvailable : 600, + ); + await expect(Math.abs(bounds.height - expectedHeight)).toBeLessThanOrEqual(1); + await expect(bounds.left).toBeGreaterThanOrEqual(15); + await expect(bounds.right).toBeLessThanOrEqual(window.innerWidth - 15); + await expect(bounds.top).toBeGreaterThanOrEqual(15); + await expect(bounds.bottom).toBeLessThanOrEqual(window.innerHeight - 15); + await expect(dialog.scrollWidth).toBeLessThanOrEqual(dialog.clientWidth); + }); + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + await userEvent.click(trigger); + await userEvent.click(await screen.findByRole('button', { name: /^Language\b/ })); + await expect(screen.getByRole('tab', { name: /suggested/i })).toHaveAttribute( + 'data-state', + 'active', + ); + await expect(screen.queryByRole('listitem', { name: /Spanish/ })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /search languages/i })); + await userEvent.type(screen.getByRole('textbox', { name: /search languages/i }), 'Spanish'); + const results = await screen.findByTestId('language-search-results'); + await waitFor(() => expect(within(results).getAllByRole('listitem')).toHaveLength(1)); + await expect(within(results).getByRole('listitem', { name: /Spanish/ })).toBeVisible(); + await expect( + within(results).queryByRole('listitem', { name: /English/ }), + ).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /^back$/i })); + await expect(screen.getByRole('tab', { name: /suggested/i })).toBeVisible(); + await userEvent.click(screen.getByRole('button', { name: /back to bible versions/i })); + await expect(screen.getByRole('textbox', { name: /search bible versions/i })).toBeVisible(); + await userEvent.click(screen.getByRole('button', { name: /^Language\b/ })); + await userEvent.click(screen.getByRole('tab', { name: /^All\b/ })); + await userEvent.click(await screen.findByRole('listitem', { name: /Spanish/ })); + await waitFor(async () => { + const openDialog = screen.getByRole('dialog'); + const heading = within(screen.getByTestId('version-list')).getByRole('heading'); + await expect(heading).toHaveTextContent('Español (América Latina)'); + const badge = within(heading).getByText('1').getBoundingClientRect(); + await expect(badge.right).toBeLessThanOrEqual(heading.getBoundingClientRect().right - 15); + await expect(openDialog.scrollWidth).toBeLessThanOrEqual(openDialog.clientWidth); + }); + await userEvent.click(screen.getByRole('listitem', { name: /Reina-Valera 1960/i })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + await expect(trigger).toHaveTextContent('RVR1960'); + }, +}; + +export const TabletPortrait: Story = { + ...tabletJourney, + tags: ['integration'], + globals: { viewport: { value: 'tabletPortrait', isRotated: false } }, +}; + +export const TabletLandscape: Story = { + ...tabletJourney, + tags: ['integration'], + globals: { viewport: { value: 'tabletLandscape', isRotated: false } }, +}; + +export const NarrowLanguageHeading: Story = { + ...tabletJourney, + tags: ['integration'], + globals: { viewport: { value: 'narrow', isRotated: false } }, +}; + +export const ShortViewport: Story = { + ...tabletJourney, + tags: ['integration'], + globals: { viewport: { value: 'short', isRotated: false } }, +}; + export const Loading: Story = { args: { versionId: 111, @@ -167,17 +292,17 @@ export const InteractiveLanguageSelection: Story = { await expect(dialog).toBeInTheDocument(); // Click language button - const languageButton = await screen.findByRole('button', { name: /select a language/i }); + const languageButton = await screen.findByRole('button', { name: /^language /i }); await waitFor(async () => { await expect(languageButton).toHaveTextContent('English'); }); await userEvent.click(languageButton); // Verify language list is visible - const selectLanguageHeading = await screen.findByRole('heading', { - name: /select a language/i, + const bibleLanguagesHeading = await screen.findByRole('heading', { + name: /bible languages/i, }); - await expect(selectLanguageHeading).toBeInTheDocument(); + await expect(bibleLanguagesHeading).toBeInTheDocument(); // Select Korean const koreanOption = await screen.findByRole('listitem', { name: /korean/i }); @@ -185,9 +310,9 @@ export const InteractiveLanguageSelection: Story = { // Wait for the language button to update with the selected language await waitFor(async () => { - await expect( - await screen.findByRole('button', { name: /select a language/i }), - ).toHaveTextContent(/korean/i); + await expect(await screen.findByRole('button', { name: /^language /i })).toHaveTextContent( + /한국어/, + ); }); }, }; @@ -233,7 +358,7 @@ export const SuggestedLanguagesTabs: Story = { await expect(dialog).toBeInTheDocument(); // Click language button to open language selection - const languageButton = await screen.findByRole('button', { name: /select a language/i }); + const languageButton = await screen.findByRole('button', { name: /^language /i }); await userEvent.click(languageButton); // Verify the Suggested tab is active by default and shows "Regional" heading @@ -250,7 +375,7 @@ export const SuggestedLanguagesTabs: Story = { // We mocked navigator.languages to ['en-US', 'ko-KR'], so English should be first, Korean second const suggestedLanguageItems = within(suggestedTabPanel).getAllByRole('listitem'); await expect(suggestedLanguageItems[1]).toHaveAttribute('aria-label', 'English'); - await expect(suggestedLanguageItems[0]).toHaveAttribute('aria-label', 'Korean'); + await expect(suggestedLanguageItems[0]?.getAttribute('aria-label')).toMatch(/Korean$/); // Verify the All tab exists with language count const allTab = await screen.findByRole('tab', { name: /all/i }); @@ -273,7 +398,7 @@ export const SuggestedLanguagesTabs: Story = { const allLanguageItems = within(allTabPanel).getAllByRole('listitem'); const languageLabels = allLanguageItems.map((item) => item.getAttribute('aria-label')); const englishIndex = languageLabels.indexOf('English'); - const koreanIndex = languageLabels.indexOf('Korean'); + const koreanIndex = languageLabels.findIndex((label) => label?.endsWith('Korean')); await expect(englishIndex).toBeLessThan(koreanIndex); // Switch back to Suggested tab and select a language @@ -289,9 +414,9 @@ export const SuggestedLanguagesTabs: Story = { // Verify the language button shows the selected language await waitFor(async () => { - await expect( - await screen.findByRole('button', { name: /select a language/i }), - ).toHaveTextContent(/english/i); + await expect(await screen.findByRole('button', { name: /^language /i })).toHaveTextContent( + /english/i, + ); }); }, }; @@ -323,7 +448,7 @@ export const SuggestedLanguagesOrder: Story = { await userEvent.click(trigger); // Open language selection - const languageButton = await screen.findByRole('button', { name: /select a language/i }); + const languageButton = await screen.findByRole('button', { name: /^language /i }); await userEvent.click(languageButton); // Verify suggested languages appear in API order (not alphabetical or by population) @@ -332,9 +457,9 @@ export const SuggestedLanguagesOrder: Story = { const labels = suggestedLanguageItems.map((item) => item.getAttribute('aria-label')); await expect(labels[0]).toBe('English'); - await expect(labels[1]).toBe('Spanish'); + await expect(labels[1]).toMatch(/Spanish$/); await expect(labels[2]).toMatch(/Portuguese/); - await expect(labels[3]).toBe('French'); + await expect(labels[3]).toMatch(/French$/); }, }; @@ -349,8 +474,9 @@ export const LanguageSearch: Story = { const trigger = await canvas.findByRole('button', { name: /NIV/i }, { timeout: 10_000 }); await userEvent.click(trigger); - const languageButton = await screen.findByRole('button', { name: /select a language/i }); + const languageButton = await screen.findByRole('button', { name: /^language /i }); await userEvent.click(languageButton); + await userEvent.click(await screen.findByRole('button', { name: /search languages/i })); const languageSearchInput = screen.getByRole('textbox', { name: /search languages/i }); await userEvent.type(languageSearchInput, 'Korean', { delay: 50 }); diff --git a/packages/ui/src/components/bible-version-picker.test.tsx b/packages/ui/src/components/bible-version-picker.test.tsx index 39fdbada7..5f7a14e54 100644 --- a/packages/ui/src/components/bible-version-picker.test.tsx +++ b/packages/ui/src/components/bible-version-picker.test.tsx @@ -180,7 +180,12 @@ async function openPicker() { async function openLanguagePanel() { await openPicker(); - await userEvent.click(screen.getByRole('button', { name: /select a language/i })); + await userEvent.click(screen.getByRole('button', { name: /^language /i })); +} + +async function openLanguageSearch() { + await openLanguagePanel(); + await userEvent.click(screen.getByRole('button', { name: /search languages/i })); } function getLanguageSearchInput() { @@ -191,159 +196,193 @@ function getVersionSearchInput() { return screen.getByRole('textbox', { name: /search bible versions/i }); } +/** Ready-to-run setup for new/edited picker workflows (see docs/testing.md). */ +function resetPickerTestEnvironment() { + vi.resetAllMocks(); + YouVersionPlatformConfiguration.permittedVersionIds = undefined; + YouVersionPlatformConfiguration.excludedVersionIds = undefined; + YouVersionPlatformConfiguration.permittedLanguageTags = undefined; + Object.defineProperty(window, 'localStorage', { + value: { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }, + writable: true, + }); +} + describe('BibleVersionPicker', () => { - beforeEach(() => { - vi.resetAllMocks(); - YouVersionPlatformConfiguration.permittedVersionIds = undefined; - YouVersionPlatformConfiguration.excludedVersionIds = undefined; - YouVersionPlatformConfiguration.permittedLanguageTags = undefined; - Object.defineProperty(window, 'localStorage', { - value: { - getItem: vi.fn(() => null), - setItem: vi.fn(), - removeItem: vi.fn(), - clear: vi.fn(), - }, - writable: true, + // Untouched cases keep suite-wide resets; new/edited workflows below use explicit setup only. + describe('legacy', () => { + beforeEach(() => { + resetPickerTestEnvironment(); }); - }); - describe('loading state', () => { - it('shows the version-list spinner even with recent versions while versions load', async () => { - vi.spyOn(window.localStorage, 'getItem').mockImplementation((key) => - key === RECENT_VERSIONS_KEY - ? JSON.stringify([{ id: 1588, title: 'Amplified Bible', localized_abbreviation: 'AMP' }]) - : null, - ); - renderPicker({ versionsLoading: true, filteredVersions: [] }); - await openPicker(); + describe('loading state', () => { + it('shows the version-list spinner even with recent versions while versions load', async () => { + vi.spyOn(window.localStorage, 'getItem').mockImplementation((key) => + key === RECENT_VERSIONS_KEY + ? JSON.stringify([ + { id: 1588, title: 'Amplified Bible', localized_abbreviation: 'AMP' }, + ]) + : null, + ); + renderPicker({ versionsLoading: true, filteredVersions: [] }); + await openPicker(); - await waitFor(() => { - const recentList = screen.getByTestId('recent-version-list'); - expect(within(recentList).getByText('Amplified Bible')).toBeInTheDocument(); - expect(recentList.parentElement?.querySelector('svg.yv\\:animate-spin')).not.toBeNull(); - }); + await waitFor(() => { + const recentList = screen.getByTestId('recent-version-list'); + expect(within(recentList).getByText('Amplified Bible')).toBeInTheDocument(); + expect(recentList.parentElement?.querySelector('svg.yv\\:animate-spin')).not.toBeNull(); + }); - expect(screen.queryByText('No versions found')).toBeNull(); + expect(screen.queryByText('No versions found')).toBeNull(); + }); }); - }); - describe('empty state', () => { - it('should show "No versions found" when not loading and results are empty', async () => { - renderPicker({ versionsLoading: false, filteredVersions: [] }); - await openPicker(); + describe('empty state', () => { + it('should show "No versions found" when not loading and results are empty', async () => { + renderPicker({ versionsLoading: false, filteredVersions: [] }); + await openPicker(); - await waitFor(() => { - expect(screen.getByText('No versions found')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByText('No versions found')).toBeInTheDocument(); + }); - const dialog = screen.getByRole('dialog'); - const spinners = dialog.querySelectorAll('svg.yv\\:animate-spin'); - expect(spinners).toHaveLength(0); + const dialog = screen.getByRole('dialog'); + const spinners = dialog.querySelectorAll('svg.yv\\:animate-spin'); + expect(spinners).toHaveLength(0); + }); }); - }); - describe('abbreviation tile', () => { - it('splits a trailing-digit abbreviation onto a second line', async () => { - renderPicker({ - versionsLoading: false, - filteredVersions: [ - { - ...mockVersions[0]!, - id: 1995, - title: 'New American Standard Bible', - localized_abbreviation: 'NASB1995', - abbreviation: 'NASB1995', - }, - ], + describe('abbreviation tile', () => { + it('splits a trailing-digit abbreviation onto a second line', async () => { + renderPicker({ + versionsLoading: false, + filteredVersions: [ + { + ...mockVersions[0]!, + id: 1995, + title: 'New American Standard Bible', + localized_abbreviation: 'NASB1995', + abbreviation: 'NASB1995', + }, + ], + }); + await openPicker(); + + const row = await screen.findByRole('listitem', { name: /new american standard bible/i }); + const media = row.querySelector('[data-slot="item-media"]'); + expect(media).not.toBeNull(); + // prefix + digits render as separate lines, not the raw concatenation + const lines = Array.from(media!.querySelectorAll('div > div')).map((n) => n.textContent); + expect(lines).toContain('NASB'); + expect(lines).toContain('1995'); }); - await openPicker(); - - const row = await screen.findByRole('listitem', { name: /new american standard bible/i }); - const media = row.querySelector('[data-slot="item-media"]'); - expect(media).not.toBeNull(); - // prefix + digits render as separate lines, not the raw concatenation - const lines = Array.from(media!.querySelectorAll('div > div')).map((n) => n.textContent); - expect(lines).toContain('NASB'); - expect(lines).toContain('1995'); - }); - it('renders the publisher name above the version title when available', async () => { - renderPicker({ versionsLoading: false, filteredVersions: mockVersions }); - await openPicker(); + it('renders the publisher name above the version title when available', async () => { + renderPicker({ versionsLoading: false, filteredVersions: mockVersions }); + await openPicker(); - const row = await screen.findByRole('listitem', { name: /new international version/i }); - const description = row.querySelector('[data-slot="item-description"]'); - expect(description).not.toBeNull(); - expect(description!.textContent).toBe('Biblica'); - }); + const row = await screen.findByRole('listitem', { name: /new international version/i }); + const description = row.querySelector('[data-slot="item-description"]'); + expect(description).not.toBeNull(); + expect(description!.textContent).toBe('Biblica'); + }); - it('omits the publisher name when the version has no organization', async () => { - renderPicker({ versionsLoading: false, filteredVersions: mockVersions }); - await openPicker(); + it('omits the publisher name when the version has no organization', async () => { + renderPicker({ versionsLoading: false, filteredVersions: mockVersions }); + await openPicker(); - const row = await screen.findByRole('listitem', { name: /new living translation/i }); - const description = row.querySelector('[data-slot="item-description"]'); - expect(description).toBeNull(); - }); + const row = await screen.findByRole('listitem', { name: /new living translation/i }); + const description = row.querySelector('[data-slot="item-description"]'); + expect(description).toBeNull(); + }); - it('hides excluded recent versions without rewriting localStorage', async () => { - const stored = [ - { - id: 111, - title: 'New International Version', - localized_abbreviation: 'NIV', - abbreviation: 'NIV', - }, - { - id: 206, - title: 'New Living Translation', - localized_abbreviation: 'NLT', - abbreviation: 'NLT', - }, - ]; - const setItem = vi.fn(); - vi.spyOn(window.localStorage, 'getItem').mockImplementation((key) => - key === RECENT_VERSIONS_KEY ? JSON.stringify(stored) : null, - ); - vi.spyOn(window.localStorage, 'setItem').mockImplementation(setItem); + it('hides excluded recent versions without rewriting localStorage', async () => { + const stored = [ + { + id: 111, + title: 'New International Version', + localized_abbreviation: 'NIV', + abbreviation: 'NIV', + }, + { + id: 206, + title: 'New Living Translation', + localized_abbreviation: 'NLT', + abbreviation: 'NLT', + }, + ]; + const setItem = vi.fn(); + vi.spyOn(window.localStorage, 'getItem').mockImplementation((key) => + key === RECENT_VERSIONS_KEY ? JSON.stringify(stored) : null, + ); + vi.spyOn(window.localStorage, 'setItem').mockImplementation(setItem); - YouVersionPlatformConfiguration.excludedVersionIds = [111]; - renderPicker({ versionsLoading: false, filteredVersions: mockVersions }); - await openPicker(); + YouVersionPlatformConfiguration.excludedVersionIds = [111]; + renderPicker({ versionsLoading: false, filteredVersions: mockVersions }); + await openPicker(); - const recentList = await screen.findByTestId('recent-version-list'); - expect(recentList.textContent).toContain('NLT'); - expect(recentList.textContent).not.toContain('NIV'); - expect(setItem).not.toHaveBeenCalled(); + const recentList = await screen.findByTestId('recent-version-list'); + expect(recentList.textContent).toContain('NLT'); + expect(recentList.textContent).not.toContain('NIV'); + expect(setItem).not.toHaveBeenCalled(); + }); }); - }); - describe('recent versions storage', () => { - it('falls back to [] when localStorage contains [null]', async () => { - vi.spyOn(window.localStorage, 'getItem').mockImplementation((key) => - key === RECENT_VERSIONS_KEY ? '[null]' : null, - ); + describe('recent versions storage', () => { + it('falls back to [] when localStorage contains [null]', async () => { + vi.spyOn(window.localStorage, 'getItem').mockImplementation((key) => + key === RECENT_VERSIONS_KEY ? '[null]' : null, + ); - expect(() => renderPicker()).not.toThrow(); - await openPicker(); + expect(() => renderPicker()).not.toThrow(); + await openPicker(); - expect(screen.queryByTestId('recent-version-list')).not.toBeInTheDocument(); - }); + expect(screen.queryByTestId('recent-version-list')).not.toBeInTheDocument(); + }); - it('falls back to en when navigator exists but languages is missing', async () => { - const user = userEvent.setup(); - const languagesDescriptor = Object.getOwnPropertyDescriptor( - globalThis.navigator, - 'languages', - ); - Object.defineProperty(globalThis.navigator, 'languages', { - value: undefined, - configurable: true, + it('falls back to en when navigator exists but languages is missing', async () => { + const user = userEvent.setup(); + const languagesDescriptor = Object.getOwnPropertyDescriptor( + globalThis.navigator, + 'languages', + ); + Object.defineProperty(globalThis.navigator, 'languages', { + value: undefined, + configurable: true, + }); + + try { + const onVersionPickerPress = vi.fn(); + renderWithOverrides( + + + , + ); + + await user.click(screen.getByRole('button')); + + expect(onVersionPickerPress).toHaveBeenCalledWith({ + versionId: 111, + languageId: 'en', + }); + } finally { + if (languagesDescriptor) { + Object.defineProperty(globalThis.navigator, 'languages', languagesDescriptor); + } + } }); + }); - try { + describe('onVersionPickerPress override', () => { + it('calls onVersionPickerPress with { versionId, languageId } when Trigger is clicked', async () => { + const user = userEvent.setup(); const onVersionPickerPress = vi.fn(); + renderWithOverrides( @@ -352,88 +391,62 @@ describe('BibleVersionPicker', () => { await user.click(screen.getByRole('button')); + expect(onVersionPickerPress).toHaveBeenCalledTimes(1); expect(onVersionPickerPress).toHaveBeenCalledWith({ versionId: 111, languageId: 'en', }); - } finally { - if (languagesDescriptor) { - Object.defineProperty(globalThis.navigator, 'languages', languagesDescriptor); - } - } - }); - }); - - describe('onVersionPickerPress override', () => { - it('calls onVersionPickerPress with { versionId, languageId } when Trigger is clicked', async () => { - const user = userEvent.setup(); - const onVersionPickerPress = vi.fn(); - - renderWithOverrides( - - - , - ); - - 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', () => { - renderWithOverrides( - - - , - ); + it('does NOT render popover content when onVersionPickerPress is provided', () => { + renderWithOverrides( + + + , + ); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument(); - }); + 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(); + it('uses controlled languageId in onVersionPickerPress payload', async () => { + const user = userEvent.setup(); + const onVersionPickerPress = vi.fn(); - renderWithOverrides( - - - , - ); + renderWithOverrides( + + + , + ); - await user.click(screen.getByRole('button')); + await user.click(screen.getByRole('button')); - expect(onVersionPickerPress).toHaveBeenCalledWith({ - versionId: 111, - languageId: 'es', + 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(); + it('does not call onVersionPickerPress when trigger click is prevented', async () => { + const user = userEvent.setup(); + const onVersionPickerPress = vi.fn(); - renderWithOverrides( - - event.preventDefault()} /> - , - ); + renderWithOverrides( + + event.preventDefault()} /> + , + ); - await user.click(screen.getByRole('button')); + await user.click(screen.getByRole('button')); - expect(onVersionPickerPress).not.toHaveBeenCalled(); + 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(); @@ -449,6 +462,7 @@ describe('BibleVersionPicker', () => { , ); + expect(screen.queryByRole('button', { name: /language/i })).not.toBeInTheDocument(); await user.click(screen.getByRole('listitem', { name: /new living translation/i })); expect(onVersionChange).toHaveBeenCalledWith(206); @@ -511,31 +525,6 @@ describe('BibleVersionPicker', () => { expect(screen.queryByText('All Languages')).not.toBeInTheDocument(); }); - it('bounds and animates the default language view inside the popover content', async () => { - const user = userEvent.setup(); - - renderPicker(); - - await openPicker(); - await user.click(screen.getByRole('button', { name: /select a language/i })); - - expect(screen.getByRole('heading', { name: /select a language/i })).toBeInTheDocument(); - - const dialog = screen.getByRole('dialog'); - const viewport = dialog.querySelector('.yv\\:relative.yv\\:min-h-0.yv\\:overflow-hidden'); - expect(viewport).not.toBeNull(); - - const animatedPanels = dialog.querySelectorAll( - '.yv\\:transition-all.yv\\:duration-300.yv\\:ease-out', - ); - expect(animatedPanels).toHaveLength(2); - - const languagePanel = screen - .getByRole('tab', { name: /suggested/i }) - .closest('.yv\\:h-full.yv\\:min-h-0.yv\\:overflow-hidden'); - expect(languagePanel).not.toBeNull(); - }); - it('open=false clears version search for pre-warmed standalone content', async () => { const user = userEvent.setup(); @@ -556,137 +545,317 @@ describe('BibleVersionPicker', () => { expect(getVersionSearchInput()).toHaveValue(''); }); + }); - it('open=false clears language search for pre-warmed standalone content', async () => { - const user = userEvent.setup(); + it('open=false clears language search for pre-warmed standalone content', async () => { + resetPickerTestEnvironment(); + const user = userEvent.setup(); + + const rootProps = { versionId: 111, onVersionPickerPress: vi.fn() }; + const { rerender } = renderWithOverrides( + + + , + ); + + await user.click(screen.getByRole('button', { name: /search languages/i })); + await user.type(getLanguageSearchInput(), 'span'); + expect(getLanguageSearchInput()).toHaveValue('span'); + expect(screen.queryByRole('tab', { name: /suggested/i })).not.toBeInTheDocument(); + + rerender( + + + , + ); + + expect(screen.queryByRole('textbox', { name: /search languages/i })).not.toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /suggested/i })).toBeInTheDocument(); + + rerender( + + + , + ); + + expect(screen.getByRole('tab', { name: /suggested/i })).toBeInTheDocument(); + expect(screen.queryByRole('textbox', { name: /search languages/i })).not.toBeInTheDocument(); + }); - const rootProps = { versionId: 111, onVersionPickerPress: vi.fn() }; - const { rerender } = renderWithOverrides( - - - , - ); + it('filters languages globally when searching', async () => { + resetPickerTestEnvironment(); + const user = userEvent.setup(); - await user.type(getLanguageSearchInput(), 'span'); - expect(getLanguageSearchInput()).toHaveValue('span'); - expect(screen.queryByRole('tab', { name: /suggested/i })).not.toBeInTheDocument(); + renderPicker(); + await openLanguageSearch(); - rerender( - - - , - ); + await user.type(getLanguageSearchInput(), 'span'); - expect(getLanguageSearchInput()).toHaveValue(''); + expect(screen.queryByRole('tab', { name: /suggested/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: /all/i })).not.toBeInTheDocument(); - rerender( - - - , - ); + const results = screen.getByTestId('language-search-results'); + expect(within(results).getAllByRole('listitem')).toHaveLength(1); + expect(within(results).getByRole('listitem', { name: /spanish/i })).toBeInTheDocument(); + }); - expect(screen.getByRole('tab', { name: /suggested/i })).toBeInTheDocument(); - }); + it('shows empty state when no languages match the search', async () => { + resetPickerTestEnvironment(); + const user = userEvent.setup(); + + renderPicker(); + await openLanguageSearch(); + + 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(); }); - describe('language search', () => { - it('filters languages globally when searching', async () => { - const user = userEvent.setup(); + it('shows loading state instead of empty results while languages are loading', async () => { + resetPickerTestEnvironment(); + const user = userEvent.setup(); - renderPicker(); - await openLanguagePanel(); + renderPicker({ languagesLoading: true }); + await openLanguageSearch(); - await user.type(getLanguageSearchInput(), 'span'); + await user.type(getLanguageSearchInput(), 'span'); - expect(screen.queryByRole('tab', { name: /suggested/i })).not.toBeInTheDocument(); - expect(screen.queryByRole('tab', { name: /all/i })).not.toBeInTheDocument(); + 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(); + }); - const results = screen.getByTestId('language-search-results'); - expect(within(results).getAllByRole('listitem')).toHaveLength(1); - expect(within(results).getByRole('listitem', { name: /spanish/i })).toBeInTheDocument(); - }); + it('clears language search when navigating back to bible versions', async () => { + resetPickerTestEnvironment(); + const user = userEvent.setup(); - it('shows empty state when no languages match the search', async () => { - const user = userEvent.setup(); + renderPicker(); + await openLanguageSearch(); - renderPicker(); - await openLanguagePanel(); + await user.type(getLanguageSearchInput(), 'korean'); + expect(getLanguageSearchInput()).toHaveValue('korean'); - await user.type(getLanguageSearchInput(), 'Koreanea'); + await user.click(screen.getByRole('button', { name: /^back$/i })); + expect(screen.queryByRole('textbox', { name: /search languages/i })).not.toBeInTheDocument(); - expect( - screen.getByText("We're sorry, there are no results for this search."), - ).toBeInTheDocument(); - expect(screen.queryByTestId('language-search-results')).not.toBeInTheDocument(); - }); + await user.click(screen.getByRole('button', { name: /back to bible versions/i })); + await user.click(screen.getByRole('button', { name: /^language /i })); + await user.click(screen.getByRole('button', { name: /search languages/i })); - it('shows loading state instead of empty results while languages are loading', async () => { - const user = userEvent.setup(); + expect(getLanguageSearchInput()).toHaveValue(''); + }); - renderPicker({ languagesLoading: true }); - await openLanguagePanel(); + it('preserves the selected tab after clearing language search', async () => { + resetPickerTestEnvironment(); + const user = userEvent.setup(); - await user.type(getLanguageSearchInput(), 'span'); + renderPicker(); + await openLanguagePanel(); - 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(); - }); + await user.click(screen.getByRole('tab', { name: /all/i })); + await user.click(screen.getByRole('button', { name: /search languages/i })); + await user.type(getLanguageSearchInput(), 'span'); + await user.clear(getLanguageSearchInput()); - it('clears language search when navigating back to bible versions', async () => { - const user = userEvent.setup(); + expect(screen.getByRole('heading', { name: /regional/i })).toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: /all/i })).not.toBeInTheDocument(); - renderPicker(); - await openLanguagePanel(); + await user.click(screen.getByRole('button', { name: /^back$/i })); - await user.type(getLanguageSearchInput(), 'korean'); - expect(getLanguageSearchInput()).toHaveValue('korean'); + expect(screen.getByRole('tab', { name: /all/i })).toHaveAttribute('aria-selected', 'true'); + }); - await user.click(screen.getByRole('button', { name: /back to bible versions/i })); - await user.click(screen.getByRole('button', { name: /select a language/i })); + it('calls onLanguageChange and clears search after selecting a filtered language', async () => { + resetPickerTestEnvironment(); + const user = userEvent.setup(); + const onLanguageChange = vi.fn(); + + renderWithOverrides( + + + + + + , + ); + + await openLanguageSearch(); + 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(); + }); + + it('puts the native language name before English and leaves English once', async () => { + resetPickerTestEnvironment(); + const user = userEvent.setup(); + renderPicker(); + await openLanguagePanel(); + + const english = screen.getByRole('listitem', { name: 'English' }); + expect(english).toHaveTextContent(/^English$/); + expect(english.className.split(' ')).toContain('yv:bg-muted'); + + await user.click(screen.getByRole('tab', { name: /all/i })); + const spanish = screen.getByRole('listitem', { name: 'Español Spanish' }); + const spanishText = spanish.textContent ?? ''; + expect(spanishText.indexOf('Español')).toBeLessThan(spanishText.indexOf('Spanish')); + expect(spanish.className.split(' ')).not.toContain('yv:bg-muted'); + }); + + it('counts All from the languages that have versions', async () => { + resetPickerTestEnvironment(); + renderPicker(); + await openLanguagePanel(); + + expect(screen.getByRole('tab', { name: `All (${mockLanguages.length})` })).toBeInTheDocument(); + }); - expect(getLanguageSearchInput()).toHaveValue(''); + it('keeps version search at the top and leaves the header pill out', async () => { + resetPickerTestEnvironment(); + renderPicker(); + await openPicker(); + + expect(screen.queryByRole('button', { name: /select a language/i })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^language /i })).toBeInTheDocument(); + expect(screen.getAllByRole('textbox', { name: /search bible versions/i })).toHaveLength(1); + }); + + it('mounts one language view, then a search view, and returns to versions', async () => { + resetPickerTestEnvironment(); + const user = userEvent.setup(); + + renderPicker(); + await openPicker(); + + expect(screen.getByRole('button', { name: 'Language English 2' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); + expect(screen.getByTestId('version-list')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /^language /i })); + + expect(screen.getByRole('heading', { name: /bible languages/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + expect(screen.queryByTestId('version-list')).not.toBeInTheDocument(); + expect(screen.queryByRole('textbox', { name: /search languages/i })).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByRole('button', { name: /back to bible versions/i })).toHaveFocus(); }); - it('preserves the selected tab after clearing language search', async () => { - const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /search languages/i })); - renderPicker(); - await openLanguagePanel(); + expect(screen.getByRole('heading', { name: /search languages/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + await waitFor(() => { + expect(getLanguageSearchInput()).toHaveFocus(); + }); + expect(screen.getByRole('heading', { name: /regional/i })).toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: /suggested/i })).not.toBeInTheDocument(); - await user.click(screen.getByRole('tab', { name: /all/i })); - await user.type(getLanguageSearchInput(), 'span'); - await user.clear(getLanguageSearchInput()); + await user.click(screen.getByRole('button', { name: /^back$/i })); - expect(screen.getByRole('tab', { name: /all/i })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('tab', { name: /suggested/i })).toBeInTheDocument(); + expect(screen.queryByRole('textbox', { name: /search languages/i })).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByRole('button', { name: /search languages/i })).toHaveFocus(); }); - it('calls onLanguageChange and clears search after selecting a filtered language', async () => { - const user = userEvent.setup(); - const onLanguageChange = vi.fn(); + await user.click(screen.getByRole('button', { name: /back to bible versions/i })); - renderWithOverrides( - + expect(screen.getByRole('heading', { name: /bible versions/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: /bible languages/i })).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Language English 2' })).toHaveFocus(); + }); + }); + + it('keeps view-change focus inside this picker when another popover-content is on the page', async () => { + resetPickerTestEnvironment(); + const user = userEvent.setup(); + + renderWithOverrides( + <> +
+ + +
+ - , - ); +
+ , + ); - await openLanguagePanel(); - await user.type(getLanguageSearchInput(), 'korean'); - await user.click(screen.getByRole('listitem', { name: /korean/i })); + await openPicker(); + await user.click(screen.getByRole('button', { name: /^language /i })); - expect(onLanguageChange).toHaveBeenCalledWith('ko'); - expect(screen.getByRole('heading', { name: /bible versions/i })).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByRole('button', { name: /back to bible versions/i })).toHaveFocus(); }); + expect(screen.getByRole('button', { name: 'Outer Back' })).not.toHaveFocus(); + + await user.click(screen.getByRole('button', { name: /back to bible versions/i })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Language English 2' })).toHaveFocus(); + }); + expect(screen.getByRole('button', { name: 'Outer Language' })).not.toHaveFocus(); + }); + + it('tabs from Close into version search, then Language, then versions', async () => { + resetPickerTestEnvironment(); + const user = userEvent.setup(); + + renderPicker(); + await openPicker(); + + const close = screen.getByRole('button', { name: 'Close' }); + close.focus(); + expect(close).toHaveFocus(); + + await user.tab(); + expect(getVersionSearchInput()).toHaveFocus(); + + await user.tab(); + expect(screen.getByRole('button', { name: 'Language English 2' })).toHaveFocus(); + + await user.tab(); + expect(screen.getByRole('listitem', { name: /new international version/i })).toHaveFocus(); + }); + + it('returns standalone language search to tabs after selecting a result without onRequestClose', async () => { + resetPickerTestEnvironment(); + const user = userEvent.setup(); + + renderWithOverrides( + + + , + ); + + await user.click(screen.getByRole('button', { name: /search languages/i })); + await user.type(getLanguageSearchInput(), 'span'); + await user.click(screen.getByRole('listitem', { name: /spanish/i })); + + expect(screen.getByRole('tab', { name: /suggested/i })).toBeInTheDocument(); + expect(screen.queryByRole('textbox', { name: /search languages/i })).not.toBeInTheDocument(); }); }); @@ -708,11 +877,12 @@ it('applies interface direction to its portaled content and Radix tabs', async ( const trigger = screen.getByRole('button', { name: 'NIV' }); expect(trigger.querySelector('bdi')).toHaveAttribute('dir', 'auto'); await userEvent.click(trigger); - await userEvent.click(screen.getByRole('button', { name: /select (?:a )?language/i })); + await userEvent.click(screen.getByRole('button', { name: /^language /i })); expect(trigger).toHaveAttribute('dir', 'rtl'); expect(screen.getByRole('dialog')).toHaveAttribute('dir', 'rtl'); expect(screen.getByRole('tablist').parentElement).toHaveAttribute('dir', 'rtl'); - expect(getLanguageSearchInput()).toHaveAttribute('dir', 'auto'); expect(screen.getAllByText('English')[0]?.closest('bdi')).toHaveAttribute('dir', 'auto'); + await userEvent.click(screen.getByRole('button', { name: /search languages/i })); + expect(getLanguageSearchInput()).toHaveAttribute('dir', 'auto'); }); diff --git a/packages/ui/src/components/bible-version-picker.tsx b/packages/ui/src/components/bible-version-picker.tsx deleted file mode 100644 index 85b418fdd..000000000 --- a/packages/ui/src/components/bible-version-picker.tsx +++ /dev/null @@ -1,1103 +0,0 @@ -'use client'; - -import { useTranslation } from 'react-i18next'; -import i18n from '@/i18n'; -import { YvComponentStyles } from '@/lib/yv-styles-components'; -import { useControllableState } from '@radix-ui/react-use-controllable-state'; -import { - getLocalStorage, - isUsableBibleVersion, - setStorageItem, - type BibleVersion, - type Language, -} from '@youversion/platform-core'; -import { - useFilteredVersions, - useLanguage, - useLanguages, - useOrganizations, - useTheme, - useVersion, - useVersions, -} from '@youversion/platform-react-hooks'; -import { - cloneElement, - createContext, - isValidElement, - type MouseEvent, - type ReactNode, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { cn } from '@/lib/utils'; -import { ChevronBackwardIcon } from './icons/chevron-backward'; -import { GlobeIcon } from './icons/globe'; -import { LoaderIcon } from './icons/loader'; -import { SearchIcon } from './icons/search'; -import { Badge } from './ui/badge'; -import { Button } from './ui/button'; -import { InputGroup, InputGroupAddon, InputGroupInput } from './ui/input-group'; -import { Item, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from './ui/item'; -import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; -import { useInterfaceDirection } from '@/lib/direction'; - -export const RECENT_VERSIONS_KEY = 'youversion-platform:picker:recent-versions'; -const MAX_RECENT_VERSIONS = 3; - -type RecentVersion = Pick< - BibleVersion, - 'id' | 'title' | 'localized_abbreviation' | 'abbreviation' | 'organization_id' ->; - -type TriggerElementProps = React.HTMLAttributes & { - 'data-yv-sdk'?: boolean; - 'data-yv-theme'?: string; -}; - -type JsonPrimitive = string | number | boolean | null; -type JsonObject = { [key: string]: JsonValue }; -type JsonValue = JsonPrimitive | JsonValue[] | JsonObject; - -function isJsonObject(value: JsonValue): value is JsonObject { - return value !== null && Object.prototype.toString.call(value) === '[object Object]'; -} - -function isRecentVersionText(value: JsonValue | undefined): value is string { - return Object.prototype.toString.call(value) === '[object String]'; -} - -function isRecentVersion(item: JsonValue): item is RecentVersion { - if (!isJsonObject(item)) { - return false; - } - return ( - Number.isFinite(item.id) && - isRecentVersionText(item.title) && - isRecentVersionText(item.localized_abbreviation) - ); -} - -function parseRecentVersions(raw: string): RecentVersion[] { - let parsed: JsonValue; - try { - // SAFETY: JSON.parse returns any. isRecentVersion checks each field - // before the list is used. - parsed = JSON.parse(raw) as JsonValue; - } catch { - return []; - } - if (!Array.isArray(parsed) || !parsed.every(isRecentVersion)) return []; - return parsed; -} - -function getRecentVersions(): RecentVersion[] { - const stored = getLocalStorage()?.getItem(RECENT_VERSIONS_KEY); - if (!stored) return []; - return parseRecentVersions(stored); -} - -function saveRecentVersions(versions: RecentVersion[]): void { - setStorageItem(getLocalStorage(), 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 the publisher (organization) name for a version when available. -// The name is resolved once at the list level (see Content) and passed in, so -// rows sharing a publisher don't each fire their own request. Renders nothing -// when no name is known, so the title stays vertically centered. -function VersionPublisherName({ name }: { name?: string | null }) { - if (!name) return null; - - return ( - - {name} - - ); -} - -// 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 }) { - const containerRef = useRef(null); - const prefixRef = useRef(null); - const [prefixSize, setPrefixSize] = useState(20); - - // Split abbreviation into letters and numbers (e.g., "KJV2" → "KJV", "2") - const match = /^(.+?)(\d+)$/.exec(text) || []; - const prefix = match[1] || text; - const digits = match[2]; - - useEffect(() => { - const container = containerRef.current; - const prefixElement = prefixRef.current; - if (!container || !prefixElement) return; - - // Calculate the maximum font size that fits the text within container bounds - const calculateSize = (element: HTMLElement | null) => { - if (!element) return 20; - - const containerWidth = container.offsetWidth; - const containerHeight = container.offsetHeight; - // Target 70% of width for horizontal padding, max 40% height for vertical spacing - const targetWidth = containerWidth * 0.7; - const maxHeight = containerHeight * 0.4; - - let currentSize = 20; - let ratio = 1; - - // Iteratively converge on the optimal size (5 iterations sufficient for convergence) - for (let i = 0; i < 5; i++) { - element.style.fontSize = `${currentSize}px`; - const currentWidth = element.scrollWidth; - const currentHeight = element.offsetHeight; - - if (currentWidth > 0) { - const widthRatio = targetWidth / currentWidth; - const heightRatio = maxHeight / currentHeight; - // Use the more restrictive constraint (width or height) - ratio = Math.min(widthRatio, heightRatio); - currentSize = currentSize * ratio; - } - } - - // Ensure minimum readable size of 12px - return Math.max(12, currentSize); - }; - - const updateSizes = () => { - const newPrefixSize = calculateSize(prefixElement); - setPrefixSize(newPrefixSize); - }; - - // Recalculate when container size changes (e.g., window resize, theme switch) - const resizeObserver = new ResizeObserver(updateSizes); - resizeObserver.observe(container); - updateSizes(); - - return () => { - resizeObserver.disconnect(); - }; - }, []); - - return ( -
-
- {prefix} -
- {digits && ( -
- {digits} -
- )} -
- ); -} - -type BibleVersionPickerContextType = { - versionId: number; - setVersionId: (versionId: number) => void; - background: 'light' | 'dark'; - side: 'top' | 'right' | 'bottom' | 'left'; - languages: Pick[]; - totalLanguages: number; - selectedLanguageId: string; - 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[]; - isLanguagesOpen: boolean; - setIsLanguagesOpen: (open: boolean) => void; - recentVersions: RecentVersion[]; - addRecentVersion: (version: RecentVersion) => void; - isPopoverOpen: boolean; - setIsPopoverOpen: (open: boolean) => void; - versionsLoading: boolean; - onVersionPickerPress?: (data: BibleVersionPickerPressData) => void; -}; - -const BibleVersionPickerContext = createContext(null); - -function useBibleVersionPickerContext() { - const context = useContext(BibleVersionPickerContext); - if (!context) { - throw new Error('BibleVersionPicker components must be used within BibleVersionPicker.Root'); - } - return context; -} - -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({ - prop: controlledVersionId, - defaultProp: controlledVersionId, - onChange: onVersionChange, - }); - - const providerTheme = useTheme(); - const theme = background || providerTheme; - - const fallbackLanguageId = - defaultLanguageId || globalThis.navigator?.languages?.[0]?.split('-')[0] || 'en'; - const [selectedLanguageId, setSelectedLanguageId] = useControllableState({ - prop: controlledLanguageId, - defaultProp: fallbackLanguageId, - 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); - } - }, - [onVersionPickerPress], - ); - - const addRecentVersion = useCallback((version: RecentVersion) => { - setRecentVersions((prev) => { - const filtered = prev.filter((v) => v.id !== version.id); - const updated = [version, ...filtered].slice(0, MAX_RECENT_VERSIONS); - saveRecentVersions(updated); - return updated; - }); - }, []); - - const { languages, loading: languagesLoading } = useLanguages({ - fields: ['id', 'display_names', 'speaking_population'], - page_size: '*', - }); - - const { versions, loading: versionsLoading } = useVersions(selectedLanguageId); - const { versions: versionsLanguageInfo, loading: versionsLanguageInfoLoading } = useVersions( - '*', - undefined, - { - fields: ['id', 'language_tag'], - page_size: '*', - }, - ); - const isLoadingLanguages = languagesLoading || versionsLanguageInfoLoading; - const filteredVersions = useFilteredVersions( - versions?.data || [], - searchQuery, - selectedLanguageId, - recentVersions, - ); - - const languageTagByVersionId = useMemo(() => { - const tags = new Map(); - for (const version of versionsLanguageInfo?.data ?? []) { - tags.set(version.id, version.language_tag); - } - return tags; - }, [versionsLanguageInfo?.data]); - - const filteredRecentVersions = useMemo(() => { - const matching = !searchQuery.trim() - ? recentVersions - : recentVersions.filter((v) => { - const query = searchQuery.trim().toLowerCase(); - return ( - v.title?.toLowerCase().includes(query) || - v.localized_abbreviation?.toLowerCase().includes(query) || - v.abbreviation?.toLowerCase().includes(query) - ); - }); - - return matching.filter((version) => - isUsableBibleVersion({ - id: version.id, - languageTag: languageTagByVersionId.get(version.id), - }), - ); - }, [recentVersions, searchQuery, languageTagByVersionId]); - - const getLanguageDisplayName = useCallback( - (language: Pick) => { - return ( - language.display_names?.[selectedLanguageId] || language.display_names?.en || language.id - ); - }, - [selectedLanguageId], - ); - - const uniqueLanguages = useMemo(() => { - const versionLanguages = - versionsLanguageInfo?.data.map((version) => version.language_tag) || []; - if (languages?.data) { - const unique = [ - ...new Map( - languages.data - .filter( - (language) => language.display_names?.en && versionLanguages.includes(language.id), - ) - .map((language) => [language.id, language]), - ).values(), - ]; - return unique.sort((a, b) => - getLanguageDisplayName(a).localeCompare(getLanguageDisplayName(b)), - ); - } - return []; - }, [languages, versionsLanguageInfo?.data, getLanguageDisplayName]); - - // pass zz to get the country languages based on IP Address - const { languages: countryLanguages } = useLanguages({ - country: 'zz', - fields: ['id', 'display_names'], - page_size: '*', - }); - - // Suggested languages = browser preference languages first, then API country - // languages (via country:'zz' which detects country by IP). Both lists are - // filtered to only languages that have at least one Bible version (uniqueLanguages). - // The API's order is preserved for country languages; browser order is preserved - // for user languages. Duplicates are removed (first occurrence wins). - const suggestedLanguages = useMemo(() => { - // Extract language codes from browser (e.g., 'en-US' -> 'en') - // Map over userLanguageCodes to preserve browser preference order - const userLanguageCodes = (globalThis.navigator?.languages ?? []).map((code) => - code.split('-')[0]?.toLowerCase(), - ); - const userLanguages = userLanguageCodes - .map((code) => uniqueLanguages.find((language) => language.id === code)) - .filter((language) => language !== undefined); - - const uniqueLanguageIds = new Set(uniqueLanguages.map((l) => l.id)); - const orderedCountryLanguages = (countryLanguages?.data ?? []).filter((language) => - uniqueLanguageIds.has(language.id), - ); - - const combined = [...userLanguages, ...orderedCountryLanguages]; - - return [...new Map(combined.map((lang) => [lang.id, lang])).values()]; - }, [uniqueLanguages, countryLanguages]); - - const filteredLanguages = filterLanguagesBySearch(uniqueLanguages, languageSearchQuery); - - const contextValue: BibleVersionPickerContextType = { - versionId, - setVersionId: setVersionIdState, - background: theme, - side, - languages: uniqueLanguages, - totalLanguages: uniqueLanguages.length || 0, - selectedLanguageId, - setSelectedLanguageId, - searchQuery, - setSearchQuery, - languageSearchQuery, - setLanguageSearchQuery, - filteredLanguages, - isLoadingLanguages, - closeLanguagePanel, - resetLanguageSearch, - suggestedLanguages, - filteredVersions, - filteredRecentVersions, - isLanguagesOpen, - setIsLanguagesOpen, - recentVersions, - addRecentVersion, - isPopoverOpen, - setIsPopoverOpen, - versionsLoading, - onVersionPickerPress, - }; - - if (onVersionPickerPress) { - return ( - - - {children} - - ); - } - - return ( - - - - {children} - - - ); -} - -/** - * Props for the version picker trigger. Interface direction owns the rendered - * trigger's `dir`; a consumer-supplied `dir` is intentionally ignored. - */ -export type BibleVersionPickerTriggerProps = Omit< - React.ComponentProps, - 'children' -> & { - children?: - | React.ReactNode - | ((props: { version: BibleVersion | null; loading: boolean }) => React.ReactNode); -}; - -function Trigger({ asChild = true, children, ...props }: BibleVersionPickerTriggerProps) { - const { t } = useTranslation(undefined, { i18n }); - const interfaceDirection = useInterfaceDirection(); - const { versionId, selectedLanguageId, background, onVersionPickerPress } = - useBibleVersionPickerContext(); - const { version, loading } = useVersion(versionId); - - const content = - children instanceof Function - ? children({ version, loading }) - : children || ( - - ); - - 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, - dir: interfaceDirection, - onClick: handlePress, - }); - } - - return ( - - ); - } - - return ( - - {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, - className, - onClick, - size = 'sm', - variant = 'secondary', - ...props -}: BibleVersionPickerLanguageTriggerProps): React.ReactElement { - const { t } = useTranslation(undefined, { i18n }); - const interfaceDirection = useInterfaceDirection(); - const { - filteredVersions, - filteredRecentVersions, - setIsLanguagesOpen, - selectedLanguageId, - versionsLoading, - } = useBibleVersionPickerContext(); - // 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 { t } = useTranslation(undefined, { i18n }); - const interfaceDirection = useInterfaceDirection(); - const { - searchQuery, - setSearchQuery, - filteredVersions, - filteredRecentVersions, - versionId, - setVersionId, - addRecentVersion, - versionsLoading, - background, - side, - isLanguagesOpen, - setIsLanguagesOpen, - setIsPopoverOpen, - closeLanguagePanel, - resetLanguageSearch, - onVersionPickerPress, - } = useBibleVersionPickerContext(); - const wasOpenRef = useRef(open ?? false); - - // Resolve publisher names once for the whole list, deduped by organization id, - // instead of mounting a fetching hook per row (avoids N+1 requests). - const { organizations } = useOrganizations([ - ...filteredRecentVersions.map((version) => version.organization_id), - ...filteredVersions.map((version) => version.organization_id), - ]); - const publisherName = (organizationId?: string | null) => - organizationId ? (organizations.get(organizationId)?.name ?? null) : null; - - const handleSelectVersion = (version: BibleVersion | RecentVersion) => { - setVersionId(version.id); - addRecentVersion({ - id: version.id, - title: version.title, - localized_abbreviation: version.localized_abbreviation, - abbreviation: version.abbreviation, - organization_id: version.organization_id, - }); - setSearchQuery(''); - onRequestClose?.(); - }; - - useEffect(() => { - if (wasOpenRef.current && open === false) { - setSearchQuery(''); - resetLanguageSearch(); - setIsLanguagesOpen(false); - } - wasOpenRef.current = open ?? false; - }, [open, resetLanguageSearch, setIsLanguagesOpen, setSearchQuery]); - - if (onVersionPickerPress && open === undefined && !onRequestClose) { - return null; - } - - if (open === undefined && !onRequestClose) { - return ( - - - {t('backToBibleVersionsAriaLabel')} - - ) : null - } - headerChild={ - - } - theme={background} - side={side} - > -
-
- setIsPopoverOpen(false)} /> -
-
- -
-
-
- ); - } - - return ( -
- {/* Versions View */} -
- {/* Recent Versions */} - {filteredRecentVersions.length > 0 && ( - <> -

- {t('recentlyUsedVersionsHeading')} -

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

{t('allVersionsHeading')}

- {filteredVersions.map((version: BibleVersion) => ( - - - - ))} -
- ) : versionsLoading ? ( -
- -
- ) : !filteredRecentVersions.length ? ( -
- {t('noVersionsFound')} -
- ) : null} -
- -
-
- - setSearchQuery(e.target.value)} - > - - - - -
-
-
- ); -} - -function LanguagePanel({ onRequestClose }: BibleLanguagePickerContentProps = {}) { - return ( -
-
- -
-
- ); -} - -function LanguageRow({ - language, - selectedLanguageId, - onSelect, -}: { - language: LanguageListItem; - selectedLanguageId: string; - onSelect: (languageId: string) => void; -}) { - return ( - - - - ); -} - -/** - * @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 { t } = useTranslation(undefined, { i18n }); - const interfaceDirection = useInterfaceDirection(); - const { - totalLanguages, - selectedLanguageId, - 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?.(); - }; - - return ( -
- {isSearching ? ( -
- {isLoadingLanguages ? ( -
- -
- ) : filteredLanguages.length > 0 ? ( - - {filteredLanguages.map((language) => ( - - ))} - - ) : ( -

- {t('noLanguageSearchResults')} -

- )} -
- ) : ( - - - - {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)} - /> - - - - -
-
-
- ); -} - -export const BibleVersionPicker = Object.assign({}, { Root, Trigger, Content }); -export type BibleVersionPickerRootProps = RootProps; diff --git a/packages/ui/src/components/bible-version-picker/index.tsx b/packages/ui/src/components/bible-version-picker/index.tsx new file mode 100644 index 000000000..3042bf7e2 --- /dev/null +++ b/packages/ui/src/components/bible-version-picker/index.tsx @@ -0,0 +1,666 @@ +'use client'; + +import { useTranslation } from 'react-i18next'; +import i18n from '@/i18n'; +import { YvComponentStyles } from '@/lib/yv-styles-components'; +import { useControllableState } from '@radix-ui/react-use-controllable-state'; +import { + getLocalStorage, + isUsableBibleVersion, + setStorageItem, + type BibleVersion, + type Language, +} from '@youversion/platform-core'; +import { + useFilteredVersions, + useLanguage, + useLanguages, + useTheme, + useVersion, + useVersions, +} from '@youversion/platform-react-hooks'; +import { + cloneElement, + createContext, + isValidElement, + type MouseEvent, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { cn } from '@/lib/utils'; +import { GlobeIcon } from '../icons/globe'; +import { LoaderIcon } from '../icons/loader'; +import { Badge } from '../ui/badge'; +import { Button } from '../ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; +import { useInterfaceDirection } from '@/lib/direction'; +import { LanguageBackButton, LanguagePanel, LanguageSearchButton } from './language-panel'; +import { VersionList } from './version-list'; + +export { BibleLanguagePickerContent } from './language-panel'; + +export const RECENT_VERSIONS_KEY = 'youversion-platform:picker:recent-versions'; +const MAX_RECENT_VERSIONS = 3; + +export type RecentVersion = Pick< + BibleVersion, + 'id' | 'title' | 'localized_abbreviation' | 'abbreviation' | 'organization_id' +>; + +type TriggerElementProps = React.HTMLAttributes & { + 'data-yv-sdk'?: boolean; + 'data-yv-theme'?: string; +}; + +type JsonPrimitive = string | number | boolean | null; +type JsonObject = { [key: string]: JsonValue }; +type JsonValue = JsonPrimitive | JsonValue[] | JsonObject; + +function isJsonObject(value: JsonValue): value is JsonObject { + return value !== null && Object.prototype.toString.call(value) === '[object Object]'; +} + +function isRecentVersionText(value: JsonValue | undefined): value is string { + return Object.prototype.toString.call(value) === '[object String]'; +} + +function isRecentVersion(item: JsonValue): item is RecentVersion { + if (!isJsonObject(item)) { + return false; + } + return ( + Number.isFinite(item.id) && + isRecentVersionText(item.title) && + isRecentVersionText(item.localized_abbreviation) + ); +} + +function parseRecentVersions(raw: string): RecentVersion[] { + let parsed: JsonValue; + try { + // SAFETY: JSON.parse returns any. isRecentVersion checks each field + // before the list is used. + parsed = JSON.parse(raw) as JsonValue; + } catch { + return []; + } + if (!Array.isArray(parsed) || !parsed.every(isRecentVersion)) return []; + return parsed; +} + +function getRecentVersions(): RecentVersion[] { + const stored = getLocalStorage()?.getItem(RECENT_VERSIONS_KEY); + if (!stored) return []; + return parseRecentVersions(stored); +} + +function saveRecentVersions(versions: RecentVersion[]): void { + setStorageItem(getLocalStorage(), RECENT_VERSIONS_KEY, JSON.stringify(versions)); +} + +export 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), + ); + }); +} + +export type PickerView = 'versions' | 'languages' | 'languageSearch'; + +type BibleVersionPickerContextType = { + versionId: number; + setVersionId: (versionId: number) => void; + background: 'light' | 'dark'; + side: 'top' | 'right' | 'bottom' | 'left'; + languages: Pick[]; + totalLanguages: number; + totalVersions: number; + selectedLanguageId: string; + setSelectedLanguageId: (id: string) => void; + searchQuery: string; + setSearchQuery: (query: string) => void; + languageSearchQuery: string; + setLanguageSearchQuery: (query: string) => void; + filteredLanguages: LanguageListItem[]; + isLoadingLanguages: boolean; + closeLanguagePanel: () => void; + closeLanguageSearch: () => void; + resetLanguageSearch: () => void; + suggestedLanguages: Pick[]; + filteredVersions: BibleVersion[]; + filteredRecentVersions: RecentVersion[]; + view: PickerView; + setView: (view: PickerView) => void; + headerOwnsChrome: boolean; + recentVersions: RecentVersion[]; + addRecentVersion: (version: RecentVersion) => void; + isPopoverOpen: boolean; + setIsPopoverOpen: (open: boolean) => void; + versionsLoading: boolean; + onVersionPickerPress?: (data: BibleVersionPickerPressData) => void; +}; + +const BibleVersionPickerContext = createContext(null); + +export function useBibleVersionPickerContext(): BibleVersionPickerContextType { + const context = useContext(BibleVersionPickerContext); + if (!context) { + throw new Error('BibleVersionPicker components must be used within BibleVersionPicker.Root'); + } + return context; +} + +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({ + prop: controlledVersionId, + defaultProp: controlledVersionId, + onChange: onVersionChange, + }); + + const providerTheme = useTheme(); + const theme = background || providerTheme; + + const fallbackLanguageId = + defaultLanguageId || globalThis.navigator?.languages?.[0]?.split('-')[0] || 'en'; + const [selectedLanguageId, setSelectedLanguageId] = useControllableState({ + prop: controlledLanguageId, + defaultProp: fallbackLanguageId, + onChange: onLanguageChange, + }); + const [searchQuery, setSearchQuery] = useState(''); + const [languageSearchQuery, setLanguageSearchQuery] = useState(''); + const [view, setView] = useState('versions'); + const [recentVersions, setRecentVersions] = useState(getRecentVersions); + const [isPopoverOpenRaw, setIsPopoverOpenRaw] = useState(false); + const isPopoverOpen = onVersionPickerPress ? false : isPopoverOpenRaw; + + const resetLanguageSearch = useCallback(() => { + setLanguageSearchQuery(''); + }, []); + + const closeLanguagePanel = useCallback(() => { + setLanguageSearchQuery(''); + setView('versions'); + }, []); + + const closeLanguageSearch = useCallback(() => { + setLanguageSearchQuery(''); + setView('languages'); + }, []); + + const setIsPopoverOpen = useCallback( + (open: boolean) => { + if (onVersionPickerPress) return; + setIsPopoverOpenRaw(open); + if (!open) { + setSearchQuery(''); + setLanguageSearchQuery(''); + setView('versions'); + } + }, + [onVersionPickerPress], + ); + + const addRecentVersion = useCallback((version: RecentVersion) => { + setRecentVersions((prev) => { + const filtered = prev.filter((v) => v.id !== version.id); + const updated = [version, ...filtered].slice(0, MAX_RECENT_VERSIONS); + saveRecentVersions(updated); + return updated; + }); + }, []); + + const { languages, loading: languagesLoading } = useLanguages({ + fields: ['id', 'display_names', 'speaking_population'], + page_size: '*', + }); + + const { versions, loading: versionsLoading } = useVersions(selectedLanguageId); + const { versions: versionsLanguageInfo, loading: versionsLanguageInfoLoading } = useVersions( + '*', + undefined, + { + fields: ['id', 'language_tag'], + page_size: '*', + }, + ); + const isLoadingLanguages = languagesLoading || versionsLanguageInfoLoading; + const filteredVersions = useFilteredVersions( + versions?.data || [], + searchQuery, + selectedLanguageId, + recentVersions, + ); + + const languageTagByVersionId = useMemo(() => { + const tags = new Map(); + for (const version of versionsLanguageInfo?.data ?? []) { + tags.set(version.id, version.language_tag); + } + return tags; + }, [versionsLanguageInfo?.data]); + + const filteredRecentVersions = useMemo(() => { + const matching = !searchQuery.trim() + ? recentVersions + : recentVersions.filter((v) => { + const query = searchQuery.trim().toLowerCase(); + return ( + v.title?.toLowerCase().includes(query) || + v.localized_abbreviation?.toLowerCase().includes(query) || + v.abbreviation?.toLowerCase().includes(query) + ); + }); + + return matching.filter((version) => + isUsableBibleVersion({ + id: version.id, + languageTag: languageTagByVersionId.get(version.id), + }), + ); + }, [recentVersions, searchQuery, languageTagByVersionId]); + + const getLanguageDisplayName = useCallback( + (language: Pick) => { + return ( + language.display_names?.[selectedLanguageId] || language.display_names?.en || language.id + ); + }, + [selectedLanguageId], + ); + + const uniqueLanguages = useMemo(() => { + const versionLanguages = + versionsLanguageInfo?.data.map((version) => version.language_tag) || []; + if (languages?.data) { + const unique = [ + ...new Map( + languages.data + .filter( + (language) => language.display_names?.en && versionLanguages.includes(language.id), + ) + .map((language) => [language.id, language]), + ).values(), + ]; + return unique.sort((a, b) => + getLanguageDisplayName(a).localeCompare(getLanguageDisplayName(b)), + ); + } + return []; + }, [languages, versionsLanguageInfo?.data, getLanguageDisplayName]); + + // pass zz to get the country languages based on IP Address + const { languages: countryLanguages } = useLanguages({ + country: 'zz', + fields: ['id', 'display_names'], + page_size: '*', + }); + + // Suggested languages = browser preference languages first, then API country + // languages (via country:'zz' which detects country by IP). Both lists are + // filtered to only languages that have at least one Bible version (uniqueLanguages). + // The API's order is preserved for country languages; browser order is preserved + // for user languages. Duplicates are removed (first occurrence wins). + const suggestedLanguages = useMemo(() => { + // Extract language codes from browser (e.g., 'en-US' -> 'en') + // Map over userLanguageCodes to preserve browser preference order + const userLanguageCodes = (globalThis.navigator?.languages ?? []).map((code) => + code.split('-')[0]?.toLowerCase(), + ); + const userLanguages = userLanguageCodes + .map((code) => uniqueLanguages.find((language) => language.id === code)) + .filter((language) => language !== undefined); + + const uniqueLanguageIds = new Set(uniqueLanguages.map((l) => l.id)); + const orderedCountryLanguages = (countryLanguages?.data ?? []).filter((language) => + uniqueLanguageIds.has(language.id), + ); + + const combined = [...userLanguages, ...orderedCountryLanguages]; + + return [...new Map(combined.map((lang) => [lang.id, lang])).values()]; + }, [uniqueLanguages, countryLanguages]); + + const filteredLanguages = filterLanguagesBySearch(uniqueLanguages, languageSearchQuery); + + const contextValue: BibleVersionPickerContextType = { + versionId, + setVersionId: setVersionIdState, + background: theme, + side, + languages: uniqueLanguages, + totalLanguages: uniqueLanguages.length || 0, + totalVersions: versionsLanguageInfo?.data?.length || 0, + selectedLanguageId, + setSelectedLanguageId, + searchQuery, + setSearchQuery, + languageSearchQuery, + setLanguageSearchQuery, + filteredLanguages, + isLoadingLanguages, + closeLanguagePanel, + closeLanguageSearch, + resetLanguageSearch, + suggestedLanguages, + filteredVersions, + filteredRecentVersions, + view, + setView, + headerOwnsChrome: false, + recentVersions, + addRecentVersion, + isPopoverOpen, + setIsPopoverOpen, + versionsLoading, + onVersionPickerPress, + }; + + if (onVersionPickerPress) { + return ( + + + {children} + + ); + } + + return ( + + + + {children} + + + ); +} + +/** + * Props for the version picker trigger. Interface direction owns the rendered + * trigger's `dir`; a consumer-supplied `dir` is intentionally ignored. + */ +export type BibleVersionPickerTriggerProps = Omit< + React.ComponentProps, + 'children' +> & { + children?: + | React.ReactNode + | ((props: { version: BibleVersion | null; loading: boolean }) => React.ReactNode); +}; + +function Trigger({ asChild = true, children, ...props }: BibleVersionPickerTriggerProps) { + const { t } = useTranslation(undefined, { i18n }); + const interfaceDirection = useInterfaceDirection(); + const { versionId, selectedLanguageId, background, onVersionPickerPress } = + useBibleVersionPickerContext(); + const { version, loading } = useVersion(versionId); + + const content = + children instanceof Function + ? children({ version, loading }) + : children || ( + + ); + + 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, + dir: interfaceDirection, + onClick: handlePress, + }); + } + + return ( + + ); + } + + return ( + + {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, + className, + onClick, + size = 'sm', + variant = 'secondary', + ...props +}: BibleVersionPickerLanguageTriggerProps): React.ReactElement { + const { t } = useTranslation(undefined, { i18n }); + const interfaceDirection = useInterfaceDirection(); + const { filteredVersions, filteredRecentVersions, setView, selectedLanguageId, versionsLoading } = + useBibleVersionPickerContext(); + // 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) { + setView('languages'); + } + }; + + return ( + + ); +} + +function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {}) { + const picker = useBibleVersionPickerContext(); + const { t } = useTranslation(undefined, { i18n }); + const { + background, + side, + view, + setIsPopoverOpen, + closeLanguagePanel, + closeLanguageSearch, + onVersionPickerPress, + } = picker; + const previousViewRef = useRef(view); + const popoverContentRef = useRef(null); + + // Keep keyboard focus inside this picker's card when VersionList and LanguagePanel remount. + // Scope via ref so an outer host popover with the same data-slot is never selected. + // Standalone Content has no PopoverContent — skip. Language search owns its own focus. + useEffect(() => { + const previous = previousViewRef.current; + previousViewRef.current = view; + if (previous === view) return; + if (view === 'languageSearch') return; + if (previous === 'languageSearch' && view === 'languages') return; + + const frame = window.requestAnimationFrame(() => { + const scope = popoverContentRef.current; + if (!scope) return; + if (view === 'languages' && previous === 'versions') { + scope.querySelector('[data-slot="language-back"]')?.focus(); + return; + } + if (view === 'versions') { + scope.querySelector('[data-slot="language-entry"]')?.focus(); + } + }); + return () => window.cancelAnimationFrame(frame); + }, [view]); + + if (onVersionPickerPress && open === undefined && !onRequestClose) { + return null; + } + + if (open === undefined && !onRequestClose) { + const heading = + view === 'versions' + ? t('bibleVersionsHeading') + : view === 'languageSearch' + ? t('searchLanguages') + : t('bibleLanguagesHeading', 'Bible Languages'); + const headingDescription = + view === 'versions' + ? t('versionsStatistics', '{{versionsCount}} Versions in {{languagesCount}} Languages', { + versionsCount: picker.totalVersions.toLocaleString(), + languagesCount: picker.totalLanguages.toLocaleString(), + }) + : undefined; + + return ( + + + ) + } + headerChild={view === 'languages' ? : null} + theme={background} + side={side} + > + {view === 'versions' ? ( + setIsPopoverOpen(false)} /> + ) : ( + + )} + + + ); + } + + return ; +} + +export const BibleVersionPicker = Object.assign({}, { Root, Trigger, Content }); +export type BibleVersionPickerRootProps = RootProps; diff --git a/packages/ui/src/components/bible-version-picker/language-panel.tsx b/packages/ui/src/components/bible-version-picker/language-panel.tsx new file mode 100644 index 000000000..c6188971e --- /dev/null +++ b/packages/ui/src/components/bible-version-picker/language-panel.tsx @@ -0,0 +1,356 @@ +'use client'; + +import { useTranslation } from 'react-i18next'; +import i18n from '@/i18n'; +import { useInterfaceDirection } from '@/lib/direction'; +import { ChevronBackwardIcon } from '../icons/chevron-backward'; +import { LoaderIcon } from '../icons/loader'; +import { SearchIcon } from '../icons/search'; +import { Button } from '../ui/button'; +import { InputGroup, InputGroupAddon, InputGroupInput } from '../ui/input-group'; +import { ItemGroup } from '../ui/item'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; +import { forwardRef, useEffect, useRef, useState, type ReactElement, type RefObject } from 'react'; +import { + useBibleVersionPickerContext, + type BibleLanguagePickerContentProps, + type LanguageListItem, +} from './index'; +import { LanguageRow } from './language-row'; + +const languageTabClassName = + 'yv:rounded-full yv:p-0 yv:text-sm yv:font-bold yv:text-foreground! yv:data-[state=active]:bg-foreground! yv:data-[state=active]:text-background! yv:data-[state=active]:shadow-none!'; + +export const LanguageSearchButton = forwardRef( + function LanguageSearchButton(_props, ref): ReactElement { + const { t } = useTranslation(undefined, { i18n }); + const { setView } = useBibleVersionPickerContext(); + + return ( + + ); + }, +); + +export const LanguageBackButton = forwardRef< + HTMLButtonElement, + { + label: string; + onClick: () => void; + } +>(function LanguageBackButton({ label, onClick }, ref): ReactElement { + return ( + + ); +}); + +export function LanguagePanel({ + onRequestClose, +}: BibleLanguagePickerContentProps = {}): ReactElement { + return ( +
+
+ +
+
+ ); +} + +function LanguageRows({ + languages, + selectedLanguageId, + onSelect, + testId, +}: { + languages: LanguageListItem[]; + selectedLanguageId: string; + onSelect: (languageId: string) => void; + testId?: string; +}): ReactElement { + return ( + + {languages.map((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 = {}): ReactElement { + const { t } = useTranslation(undefined, { i18n }); + const interfaceDirection = useInterfaceDirection(); + const { + totalLanguages, + selectedLanguageId, + setSelectedLanguageId, + suggestedLanguages, + languages, + filteredLanguages, + languageSearchQuery, + setLanguageSearchQuery, + resetLanguageSearch, + closeLanguageSearch, + isLoadingLanguages, + background, + view, + setView, + headerOwnsChrome, + } = useBibleVersionPickerContext(); + + const [languageTab, setLanguageTab] = useState('suggested'); + const wasOpenRef = useRef(open ?? false); + const panelRootRef = useRef(null); + const searchOpenRef = useRef(null); + const backRef = useRef(null); + const searchInputRef = useRef(null); + + // RN standalone/Expo integrations keep this content mounted and toggle `open`. + // Reopening should land on the language tabs, not a stale search screen. + useEffect(() => { + if (wasOpenRef.current && open === false) { + resetLanguageSearch(); + if (view === 'languageSearch') { + setView('languages'); + } + } + wasOpenRef.current = open ?? false; + }, [open, resetLanguageSearch, setView, view]); + + useEffect(() => { + if (view !== 'languageSearch') return; + searchInputRef.current?.focus(); + return () => { + const opener = searchOpenRef.current; + const back = backRef.current; + if (opener ?? back) { + (opener ?? back)?.focus(); + return; + } + const scope = + panelRootRef.current?.closest('[data-slot="popover-content"]') ?? panelRootRef.current; + const scopedOpener = scope?.querySelector('[data-slot="language-search-open"]'); + const scopedBack = scope?.querySelector('[data-slot="language-back"]'); + (scopedOpener ?? scopedBack)?.focus(); + }; + }, [view]); + + const handleSelectLanguage = (languageId: string) => { + setSelectedLanguageId(languageId); + resetLanguageSearch(); + // Return to tabs before the host close callback so a delayed/absent close + // does not leave the user on an empty Search Languages view. + setView('languages'); + onRequestClose?.(); + }; + + const searching = view === 'languageSearch'; + const query = languageSearchQuery.trim(); + + return ( +
+ {searching ? ( + + ) : ( + <> + {headerOwnsChrome ? null : ( +
+ +
+ )} + + + + {t('suggestedTab')} + + + {t('allLanguagesTab', { count: totalLanguages })} + + + + + {suggestedLanguages.length > 0 ? ( + <> +

+ {t('regionalHeading')} +

+ + + ) : ( +

+ {t('noRegionalLanguagesAvailable')} +

+ )} +
+ + +

+ {t('allLanguagesHeading')} +

+ +
+
+ + )} +
+ ); +} + +function LanguageSearchView({ + backRef, + closeLanguageSearch, + filteredLanguages, + headerOwnsChrome, + isLoadingLanguages, + languageSearchQuery, + onQueryChange, + onSelect, + query, + searchInputRef, + selectedLanguageId, + suggestedLanguages, +}: { + backRef: RefObject; + closeLanguageSearch: () => void; + filteredLanguages: LanguageListItem[]; + headerOwnsChrome: boolean; + isLoadingLanguages: boolean; + languageSearchQuery: string; + onQueryChange: (query: string) => void; + onSelect: (languageId: string) => void; + query: string; + searchInputRef: RefObject; + selectedLanguageId: string; + suggestedLanguages: LanguageListItem[]; +}): ReactElement { + const { t } = useTranslation(undefined, { i18n }); + + return ( + <> + {headerOwnsChrome ? null : ( +
+ +
+ )} +
+ + onQueryChange(event.target.value)} + /> + + + + +
+
+ {query ? ( + isLoadingLanguages ? ( +
+ +
+ ) : filteredLanguages.length > 0 ? ( + + ) : ( +

+ {t('noLanguageSearchResults')} +

+ ) + ) : suggestedLanguages.length > 0 ? ( + <> +

+ {t('regionalHeading')} +

+ + + ) : ( +

+ {t('noRegionalLanguagesAvailable')} +

+ )} +
+ + ); +} diff --git a/packages/ui/src/components/bible-version-picker/language-row.tsx b/packages/ui/src/components/bible-version-picker/language-row.tsx new file mode 100644 index 000000000..7774371de --- /dev/null +++ b/packages/ui/src/components/bible-version-picker/language-row.tsx @@ -0,0 +1,67 @@ +'use client'; + +import type { ReactElement } from 'react'; +import { cn } from '@/lib/utils'; +import { Button } from '../ui/button'; +import { Item, ItemContent, ItemDescription, ItemTitle } from '../ui/item'; +import type { LanguageListItem } from './index'; + +type LanguageRowNames = { + primary: string; + secondary?: string; +}; + +export function languageRowNames( + language: Pick, +): LanguageRowNames { + const primary = + language.display_names?.[language.id] || language.display_names?.en || language.id; + const english = language.display_names?.en; + if (!english || english === primary) return { primary }; + return { primary, secondary: english }; +} + +export function LanguageRow({ + language, + selectedLanguageId, + onSelect, +}: { + language: LanguageListItem; + selectedLanguageId: string; + onSelect: (languageId: string) => void; +}): ReactElement { + const { primary, secondary } = languageRowNames(language); + const label = secondary ? `${primary} ${secondary}` : primary; + + return ( + + + + ); +} diff --git a/packages/ui/src/components/bible-version-picker/version-list.tsx b/packages/ui/src/components/bible-version-picker/version-list.tsx new file mode 100644 index 000000000..186acfc97 --- /dev/null +++ b/packages/ui/src/components/bible-version-picker/version-list.tsx @@ -0,0 +1,347 @@ +'use client'; + +import { useTranslation } from 'react-i18next'; +import i18n from '@/i18n'; +import { useInterfaceDirection } from '@/lib/direction'; +import { cn } from '@/lib/utils'; +import { type BibleVersion } from '@youversion/platform-core'; +import { useLanguage, useOrganizations } from '@youversion/platform-react-hooks'; +import { useEffect, useRef, useState, type ReactElement } from 'react'; +import { ChevronForwardIcon } from '../icons/chevron-forward'; +import { GlobeIcon } from '../icons/globe'; +import { LoaderIcon } from '../icons/loader'; +import { SearchIcon } from '../icons/search'; +import { InputGroup, InputGroupAddon, InputGroupInput } from '../ui/input-group'; +import { Item, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from '../ui/item'; +import { + useBibleVersionPickerContext, + type BibleVersionPickerContentProps, + type RecentVersion, +} from './index'; +import { languageRowNames } from './language-row'; + +// The name is resolved once at the list level and passed in, so rows sharing a +// publisher don't each fire their own request. Renders nothing when no name is +// known, so the title stays vertically centered. +function VersionPublisherName({ name }: { name?: string | null }) { + if (!name) return null; + + return ( + + {name} + + ); +} + +// Scales the abbreviation to the icon box. A trailing digit wraps onto a +// second line so "KJV2" stays readable inside the fixed tile. +function VersionAbbreviationIcon({ text }: { text: string }) { + const containerRef = useRef(null); + const measureRef = useRef(null); + const [prefixSize, setPrefixSize] = useState(20); + const prefixSizeRef = useRef(20); + + const match = /^(.+?)(\d+)$/.exec(text) || []; + const prefix = match[1] || text; + const digits = match[2]; + + useEffect(() => { + const container = containerRef.current; + const measureElement = measureRef.current; + if (!container || !measureElement) return; + + const calculateSize = () => { + const containerWidth = container.offsetWidth; + const containerHeight = container.offsetHeight; + if (containerWidth === 0 || containerHeight === 0) { + return prefixSizeRef.current; + } + + const targetWidth = containerWidth * 0.7; + const maxHeight = containerHeight * 0.4; + + let currentSize = 20; + + for (let i = 0; i < 5; i++) { + measureElement.style.fontSize = `${currentSize}px`; + const currentWidth = measureElement.scrollWidth; + const currentHeight = measureElement.offsetHeight; + + if (currentWidth > 0) { + const widthRatio = targetWidth / currentWidth; + const heightRatio = maxHeight / currentHeight; + const ratio = Math.min(widthRatio, heightRatio); + currentSize = currentSize * ratio; + } + } + + return Math.max(12, Math.round(currentSize)); + }; + + const updateSizes = () => { + const newPrefixSize = calculateSize(); + if (newPrefixSize === prefixSizeRef.current) return; + prefixSizeRef.current = newPrefixSize; + setPrefixSize(newPrefixSize); + }; + + const resizeObserver = new ResizeObserver(updateSizes); + resizeObserver.observe(container); + updateSizes(); + + return () => { + resizeObserver.disconnect(); + }; + }, [prefix]); + + return ( +
+
+ {prefix} +
+
+ {prefix} +
+ {digits && ( +
+ {digits} +
+ )} +
+ ); +} + +function VersionCountBadge({ count, loading }: { count: number; loading: boolean }): ReactElement { + return ( + + {loading ? + ); +} + +function LanguageEntry({ name }: { name: string }): ReactElement { + const { t } = useTranslation(undefined, { i18n }); + const { filteredVersions, filteredRecentVersions, setView, versionsLoading } = + useBibleVersionPickerContext(); + const count = filteredVersions.length + filteredRecentVersions.length; + + return ( + + ); +} + +export function VersionList({ + open, + onRequestClose, +}: BibleVersionPickerContentProps = {}): ReactElement { + const { t } = useTranslation(undefined, { i18n }); + const interfaceDirection = useInterfaceDirection(); + const { + searchQuery, + setSearchQuery, + filteredVersions, + filteredRecentVersions, + versionId, + setVersionId, + addRecentVersion, + versionsLoading, + background, + resetLanguageSearch, + setView, + selectedLanguageId, + headerOwnsChrome, + } = useBibleVersionPickerContext(); + const { language: selectedLanguage } = useLanguage(selectedLanguageId); + const selectedLanguageName = selectedLanguage + ? languageRowNames({ + id: selectedLanguageId, + display_names: selectedLanguage.display_names, + }).primary + : selectedLanguageId; + const wasOpenRef = useRef(open ?? false); + + const { organizations } = useOrganizations([ + ...filteredRecentVersions.map((version) => version.organization_id), + ...filteredVersions.map((version) => version.organization_id), + ]); + const publisherName = (organizationId?: string | null) => + organizationId ? (organizations.get(organizationId)?.name ?? null) : null; + + const handleSelectVersion = (version: BibleVersion | RecentVersion) => { + setVersionId(version.id); + addRecentVersion({ + id: version.id, + title: version.title, + localized_abbreviation: version.localized_abbreviation, + abbreviation: version.abbreviation, + organization_id: version.organization_id, + }); + setSearchQuery(''); + onRequestClose?.(); + }; + + useEffect(() => { + if (wasOpenRef.current && open === false) { + setSearchQuery(''); + resetLanguageSearch(); + setView('versions'); + } + wasOpenRef.current = open ?? false; + }, [open, resetLanguageSearch, setSearchQuery, setView]); + + const versionTileClassName = + 'yv:rounded-[6px] yv:size-12 yv:bg-secondary yv:border yv:border-border yv:flex yv:flex-col yv:justify-center yv:items-center'; + const versionRowClassName = (selected: boolean) => + cn('yv:hover:bg-muted yv:rounded-lg', selected ? 'yv:bg-muted' : ''); + + return ( +
+
+
+ + setSearchQuery(e.target.value)} + > + + + + +
+ {headerOwnsChrome ? : null} +
+
+ {filteredRecentVersions.length > 0 && ( + <> +

+ {t('recentlyUsedVersionsHeading')} +

+ + {filteredRecentVersions.map((version) => ( + + + + ))} + + + )} + {filteredVersions.length > 0 ? ( + +

+ + {t('languageVersionsHeading', '{{language}} Versions', { + language: selectedLanguageName, + })} + + +

+ {filteredVersions.map((version: BibleVersion) => ( + + + + ))} +
+ ) : versionsLoading ? ( +
+ +
+ ) : !filteredRecentVersions.length ? ( +
+ {t('noVersionsFound')} +
+ ) : null} +
+
+ ); +} diff --git a/packages/ui/src/components/ui/popover.tsx b/packages/ui/src/components/ui/popover.tsx index 7ffdfb9a1..a01664c32 100644 --- a/packages/ui/src/components/ui/popover.tsx +++ b/packages/ui/src/components/ui/popover.tsx @@ -25,17 +25,27 @@ function PopoverContent({ children, headerLeading, headerChild, + headerClassName, + closeButtonClassName, align = 'center', heading, + headingDescription, showHeader = true, + showClose = true, sideOffset = 4, theme = 'light', ...props }: React.ComponentProps & { showHeader?: boolean; + showClose?: boolean; heading?: React.ReactNode; + headingDescription?: React.ReactNode; headerLeading?: React.ReactNode; headerChild?: React.ReactNode; + /** Overrides the shared muted header shell (e.g. version picker flat header). */ + headerClassName?: string; + /** Overrides the shared Close button classes (e.g. version picker bordered Close). */ + closeButtonClassName?: string; theme?: 'light' | 'dark'; }): React.ReactNode { const { t } = useTranslation(undefined, { i18n }); @@ -59,26 +69,34 @@ function PopoverContent({ > {showHeader ? (
{headerLeading} -

{heading}

+
+

{heading}

+ {headingDescription ? ( +

+ {headingDescription} +

+ ) : null} +
{headerChild} - - - + {showClose ? ( + + + + ) : null}
) : null} {children} @@ -87,6 +105,15 @@ function PopoverContent({ ); } +function popoverHeaderColumns(hasLeading: boolean, hasChild: boolean, showClose: boolean): string { + if (hasLeading && hasChild && showClose) return 'yv:grid-cols-[auto_1fr_auto_auto]'; + if (hasLeading && (hasChild || showClose)) return 'yv:grid-cols-[auto_1fr_auto]'; + if (hasLeading) return 'yv:grid-cols-[auto_1fr]'; + if (hasChild && showClose) return 'yv:grid-cols-[1fr_auto_auto]'; + if (hasChild || showClose) return 'yv:grid-cols-[1fr_auto]'; + return 'yv:grid-cols-[1fr]'; +} + function PopoverClose({ ...props }: React.ComponentProps): React.ReactNode { diff --git a/packages/ui/tsup.config.ts b/packages/ui/tsup.config.ts index d4fb21abd..f35d27039 100644 --- a/packages/ui/tsup.config.ts +++ b/packages/ui/tsup.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ 'src/components/bible-reader-search.tsx', 'src/components/bible-reader-navigation.ts', 'src/components/bible-chapter-picker.tsx', - 'src/components/bible-version-picker.tsx', + 'src/components/bible-version-picker/index.tsx', 'src/components/YouVersionAuthButton.tsx', 'src/components/verse-of-the-day.tsx', 'src/components/verse.tsx', diff --git a/plans/20261022-1421-language-picker-v2/design-sketch.md b/plans/20261022-1421-language-picker-v2/design-sketch.md new file mode 100644 index 000000000..34b550160 --- /dev/null +++ b/plans/20261022-1421-language-picker-v2/design-sketch.md @@ -0,0 +1,72 @@ +# Bible picker v2, PR1 + +Sketch for phases 01, 02, and 03. Types and module ownership only. PR1 stays in the current card. Phase 04 is PR2. + +## View + +```ts +type PickerView = 'versions' | 'languages' | 'languageSearch' +``` + +`view` names the mounted screen. The Suggested/All tab is a separate field, so Back from `languageSearch` can return to `languages` and keep the tab. Phase 01. + +## Context fields that change + +```ts +view: PickerView +languageSearchQuery: string +headerOwnsChrome: boolean +``` + +`headerOwnsChrome` is true only on the SDK popover shell. Standalone `BibleLanguagePickerContent` draws the search icon inside the languages panel when the flag is false. The shell sets the flag so the icon is drawn once. Phase 01. + +Back from `languageSearch` clears `languageSearchQuery` and keeps the tab. Back from `languages` clears the query through the existing `closeLanguagePanel` path. When `open` becomes false, the query clears and the next language screen is `languages`. Version `searchQuery` still clears on close. Phase 02. + +## PopoverContent + +```ts +showClose?: boolean // default true +``` + +| View | showClose | Header slot | +| --- | --- | --- | +| versions | true | Pill in phase 01. Empty after phase 02, when the language row replaces the pill. | +| languages | false | Search icon. Sets `view` to `languageSearch`. | +| languageSearch | false | Empty. The field is in the body and takes focus. | + +Reader settings and every other popover keep the default, so Close stays. Phase 01. + +## Transitions + +| From | Action | Result | +| --- | --- | --- | +| versions | Language control, or the phase 02 language row | `languages` | +| languages | Back | `versions`, query cleared | +| languages | Search icon | `languageSearch` | +| languages | Pick a language | Set id, clear search, `onRequestClose` | +| languageSearch | Back | `languages`, query cleared, tab kept | +| languageSearch | Pick a language | Set id, clear search, `onRequestClose` | +| any | `open` becomes false | Query cleared. Next language screen is `languages` | + +The search field exists only on `languageSearch`. One view is mounted, and the dialog has one heading. Phase 01. Prefer no crossfade. A leftover transition uses `motion-reduce:transition-none`. + +## Module map + +`packages/ui/src/components/bible-version-picker/`. Delete `bible-version-picker.tsx` in the same change so `./bible-version-picker` resolves to the folder. Phase 01. + +- `index.tsx` holds Root, Trigger, context, recent-version helpers, and the public exports. `RECENT_VERSIONS_KEY` and `BibleVersionPickerLanguageTrigger` stay exported. +- `version-list.tsx` holds the versions body. Phase 02 moves version search to the top, places the language row under it, and removes the bottom version search. After that, `headerChild` is empty. +- `language-panel.tsx` holds `LanguagePanel`, `BibleLanguagePickerContent`, and the Suggested/All tabs. Phase 03 sets high-contrast pill classes on `TabsList` and `TabsTrigger` here. +- `language-row.tsx` holds `LanguageRow`. Phase 03 adds this signature in that file. + +```ts +function languageRowNames(language): { primary: string; secondary?: string } +``` + +Primary is the native name, then English, then the id. Secondary is English when it differs from primary. The versions row may call the helper so the selected name matches the list. Sorting stays on `getLanguageDisplayName`. + +## Rejected + +**`isLanguagesOpen`.** A boolean names two screens. A second flag then allows search-while-versions. `view` already names the mounted screen, so phase 01 step 6 drops the boolean once call sites read `view`. + +**Two-layer crossfade.** The old pair of panels was versions and languages. A third screen has no layer, and the hidden layer stays in the accessibility tree. Mount the active view only. The test that expects two animated panels moves to phase 06. Phase 01. diff --git a/plans/20261022-1421-language-picker-v2/phase-01-panel-state-and-search.md b/plans/20261022-1421-language-picker-v2/phase-01-panel-state-and-search.md new file mode 100644 index 000000000..c8273e584 --- /dev/null +++ b/plans/20261022-1421-language-picker-v2/phase-01-panel-state-and-search.md @@ -0,0 +1,112 @@ +# Phase 01 — Panel state and search + +## Context Links + +- [plan.md](plan.md) +- [research/researcher-01-figma-changelog.md](research/researcher-01-figma-changelog.md) items 1–2 +- [scout/scout-01-picker-codebase.md](scout/scout-01-picker-codebase.md) +- [reports/synthesis.md](reports/synthesis.md) + +## Overview + +Split the picker into a folder. Replace the languages boolean with three views. Add the Search Languages screen. Remove the bottom language field. Leave version search, row layout, and pills for later phases. + +## Key Insights + +- `Content` calls itself. Outer call is the popover shell. Inner call is the versions body. Split those on purpose. +- Two stacked crossfade layers cannot represent three views and stay in the a11y tree. Mount one view. +- `BibleLanguagePickerContent` is public and used without the popover header. Standalone still needs a way to open search. +- Test `bounds and animates…` expects 2 animated panels. That test changes in phase 06. Do not keep the crossfade for it. +- `closeLanguagePanel` already clears `languageSearchQuery`. Keep that when returning to versions. + +## Requirements + +- Views: `versions` | `languages` | `languageSearch`. +- Languages header: Back (existing `backToBibleVersionsAriaLabel`), title `t('selectLanguageHeading')`, search icon button (`languageSearchAriaLabel`). No Close on this view. +- Search view: title `t('searchLanguages')`. Back returns to `languages`, clears the query, keeps the Suggested/All tab. Field on top. Autofocus. `placeholder` = `searchPlaceholder`. `aria-label` = `languageSearchAriaLabel`. +- Empty search view shows Regional (`suggestedLanguages` + `regionalHeading`), not the tabs. +- Non-empty query shows `filteredLanguages` or `noLanguageSearchResults` or the loader. Same `filterLanguagesBySearch` rules. `data-testid="language-search-results"` stays. +- Selecting a language still sets id, clears search, calls `onRequestClose`. +- `open === false` still clears language search and leaves the next open on tabs (languages view, not search). +- Bottom language `
` is gone in both SDK and standalone. +- Version bottom search still works after this phase. +- `onVersionPickerPress` null render unchanged. +- `motion-reduce:transition-none` if any transition remains. Prefer no crossfade. + +## Architecture + +``` +PickerView = 'versions' | 'languages' | 'languageSearch' +headerOwnsChrome: boolean // true only for SDK popover shell +``` + +Popover shell reads the view and passes `heading`, `headerLeading`, `headerChild`, `showClose` (phase 01 adds `showClose` default `true` on `PopoverContent`). + +| View | showClose | headerChild | +| --- | --- | --- | +| versions | true | unchanged pill (phase 02 removes it) | +| languages | false | icon button → `languageSearch` | +| languageSearch | false | none | + +`BibleLanguagePickerContent`: if `!headerOwnsChrome` and view is `languages`, render the same search icon above the tabs. SDK shell sets the flag so the icon is not doubled. + +Search field lives only in the `languageSearch` branch. + +Inactive view unmounted. One heading in the dialog. + +## Related Code Files + +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/bible-version-picker.tsx` (split, then delete) +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/ui/popover.tsx` +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/index.ts` +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/icons/search.tsx` +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/icons/chevron-backward.tsx` +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/bible-version-picker.test.tsx` +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/AGENTS.md` + +## Implementation Steps + +1. Create `packages/ui/src/components/bible-version-picker/`. +2. Move `Root`, `Trigger`, context, recent-version helpers, and public exports into `index.tsx`. Re-export every symbol `components/index.ts` and the test already import (`RECENT_VERSIONS_KEY` included). +3. Move the versions body (today's second `Content` return) into `version-list.tsx` unchanged. +4. Move `LanguagePanel`, `LanguageRow`, `BibleLanguagePickerContent` into `language-panel.tsx` and `language-row.tsx`. Row markup stays until phase 03. +5. Delete `bible-version-picker.tsx`. Specifier `./bible-version-picker` must hit the folder. +6. Add `view` state. Keep a derived `isLanguagesOpen = view !== 'versions'` only if something still reads it. Prefer deleting the boolean once call sites use `view`. +7. Add `showClose?: boolean` to `PopoverContent` (default `true`). Reader settings must still show X. Pass `showClose={false}` for both language views. +8. Wire header search icon and search view. Autofocus with a ref + `useEffect` when `view` becomes `languageSearch`. On unmount of that view, move focus to Back or the search icon (whichever is visible). +9. Delete the bottom language search section. +10. Standalone: in-content search button when `headerOwnsChrome` is false. +11. Run the picker unit test. Failures tied to "input always mounted" or "2 panels" are expected; fix them in phase 06 before the PR merges. Do not revert the view model to green those tests. + +## Todo + +- [ ] Folder split, flat file removed, public exports unchanged +- [ ] Three views, one mounted at a time +- [ ] Languages header: Back, title, search icon, no Close +- [ ] Search view: top field, autofocus, Regional until query, Back to list +- [ ] Bottom language search removed +- [ ] Standalone language content can still open search +- [ ] `showClose` defaults true for other popovers +- [ ] Version search still at the bottom (phase 02 moves it) + +## Success Criteria + +- From the SDK picker: Versions → language control → Languages with no X and no bottom field → search icon → focused field and Regional → type a query → results → Back → tabs, query empty, previous tab still selected. +- Back from Languages returns to Versions and clears the query. +- Standalone `BibleLanguagePickerContent` can search without a popover header. +- `pnpm --filter @youversion/platform-react-ui exec vitest run --project unit src/components/bible-reader.tsx` not required; spot-check that reader settings popover still has Close (unit or story). No Sheet yet. + +## Risk + +- Folder + file name clash if both `bible-version-picker.tsx` and `bible-version-picker/index.tsx` exist. Delete the flat file in the same change. +- Hiding Close with a default-wrong prop removes X from reader settings. Default `true`. +- Hosts that automated the bottom language field will break. The replacement is the search view. Call that out in the PR. +- Focus can land on `body` after Back. Send it to the search icon or Back explicitly. + +## Security + +Search filters data already on the client. Do not log the query. Do not add a network call. Escape still closes the popover (Radix). Do not remove keyboard dismiss when hiding the X. + +## Next Steps + +[phase-02-versions-entry-and-search.md](phase-02-versions-entry-and-search.md) diff --git a/plans/20261022-1421-language-picker-v2/phase-02-versions-entry-and-search.md b/plans/20261022-1421-language-picker-v2/phase-02-versions-entry-and-search.md new file mode 100644 index 000000000..064aed895 --- /dev/null +++ b/plans/20261022-1421-language-picker-v2/phase-02-versions-entry-and-search.md @@ -0,0 +1,90 @@ +# Phase 02 — Versions entry and search + +## Context Links + +- [plan.md](plan.md) +- [research/researcher-01-figma-changelog.md](research/researcher-01-figma-changelog.md) item 6 +- [research/researcher-02-matchability-and-defaults.md](research/researcher-02-matchability-and-defaults.md) +- [scout/scout-01-picker-codebase.md](scout/scout-01-picker-codebase.md) + +## Overview + +Versions gets a top search field and a full-width Language row under it. Header pill goes away. Bottom version search goes away. No subtitle unless a real string already exists (it does not). + +## Key Insights + +- Pill component `BibleVersionPickerLanguageTrigger` is public. Tests and hosts render it directly. Stop using it in the SDK header. Do not delete the export. +- Badge count is `filteredVersions.length + filteredRecentVersions.length`, with a spinner while `versionsLoading`. Reuse that. Do not unique the set. +- Accessible name of the pill is `selectLanguageAriaLabel` ("Select a Language"). The row's visible text becomes `t('language')` plus the language name. Tests that click `/select a language/i` inside the header must target the row (phase 06). +- `getVersionSearchInput` finds `versionSearchAriaLabel`. Moving the field is safe if the name stays. +- Subtitle: omit. `allVersionsHeading` is not a count line. + +## Requirements + +- Order inside Versions, top to bottom: header (title + Close), version search, language row, scroller (recent, then all). +- Search field: full width, same `versionSearchAriaLabel`, `searchPlaceholder`, same `searchQuery` behavior (filters recent and all). +- Language row, full width, one button: `GlobeIcon`, label `t('language')`, selected language name, count (or loader), `ChevronForwardIcon`. +- Click runs the same action as the pill: `set` view to `languages`, unless `onClick` calls `preventDefault` (keep that contract on the public trigger; the row is the in-picker control and always opens). +- Name text: native primary (`display_names[id] || display_names.en || id`) so it matches the list. Wrap in ``. +- Header `headerChild` is null whenever the row is shown (SDK popover and the versions body). +- No bottom version-search section. +- No invented "N versions" subtitle. +- Standalone `Content` (host body) gets the same top search + row. It has no popover header, so there is no pill to hide there today. Row still opens languages only if the host mounted the language view. If this body cannot switch views by itself, the row calls the same context setter the pill uses so a host that renders both pieces still works. Do not require `onVersionPickerPress` hosts to implement a new callback. + +## Architecture + +Put the row in `version-list.tsx` (or `language-entry.tsx` if the list file gets crowded). It reads context: `selectedLanguageId`, `useLanguage`, `filteredVersions`, `filteredRecentVersions`, `versionsLoading`, view setter. + +Do not render `BibleVersionPickerLanguageTrigger` inside `PopoverContent` `headerChild`. + +Chevron: `ChevronForwardIcon` from `icons/chevron-forward.tsx`. Do not use physical left/right icons. `dir` on the row follows interface direction. + +## Related Code Files + +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/bible-version-picker/version-list.tsx` (after phase 01) +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/bible-version-picker/index.tsx` +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/bible-version-picker.tsx` (only if phase 01 has not landed) +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/icons/globe.tsx` +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/icons/chevron-forward.tsx` +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/ui/popover.tsx` +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/i18n/locales/en.json` (read `language`; do not edit) + +## Implementation Steps + +1. Move the version `
` search from the bottom of the body to the top. Keep the input props. +2. Render the language row directly under that field, outside the scroll region so it stays put (match the header density; if the mock scrolls it away, scroll it with the list — default: sticky under the search, not inside the results). +3. Remove `BibleVersionPickerLanguageTrigger` from `headerChild`. +4. Delete the bottom search section. +5. Do not add subtitle markup. +6. Confirm `open=false` still clears `searchQuery` (existing effect on `Content`). + +## Todo + +- [ ] Top version search, bottom one gone +- [ ] Full-width language row with globe, `language` label, name, count, chevron +- [ ] Count uses the existing sum; loader while versions load +- [ ] Header pill not rendered +- [ ] `BibleVersionPickerLanguageTrigger` still exported +- [ ] No subtitle string added + +## Success Criteria + +- Open picker: search field is the first body control. Language row is under it. No pill in the header. No second search at the bottom. +- Typing still filters recent and all versions. Selecting a version still closes and clears search. +- Row click opens Languages. +- Count matches the old badge for the same data, including the double-count. Do not "fix" it. +- Header has no extra total line. + +## Risk + +- Sticky row plus `66svh` can clip the list. If the first recent row is hidden, drop sticky and let the row scroll. Do not add a new scroll container. +- Removing the pill changes the only in-picker control named "Select a Language". Update tests in phase 06 in the same PR so CI is not red. +- Public trigger still shows English (`display_names.en`). The new row uses native primary. That split is intentional: the row matches item 5; do not restyle the public pill in this phase. + +## Security + +No new storage. Recent versions stay in the existing key. Count is a length, not user HTML. Language name is text inside `bdi`, same as today. + +## Next Steps + +[phase-03-language-rows-and-tabs.md](phase-03-language-rows-and-tabs.md) diff --git a/plans/20261022-1421-language-picker-v2/phase-03-language-rows-and-tabs.md b/plans/20261022-1421-language-picker-v2/phase-03-language-rows-and-tabs.md new file mode 100644 index 000000000..32d8895db --- /dev/null +++ b/plans/20261022-1421-language-picker-v2/phase-03-language-rows-and-tabs.md @@ -0,0 +1,96 @@ +# Phase 03 — Language rows and tabs + +## Context Links + +- [plan.md](plan.md) +- [research/researcher-01-figma-changelog.md](research/researcher-01-figma-changelog.md) items 3–5 +- [research/researcher-02-matchability-and-defaults.md](research/researcher-02-matchability-and-defaults.md) +- [scout/scout-01-picker-codebase.md](scout/scout-01-picker-codebase.md) + +## Overview + +Restyle language rows and the Suggested/All pills. Selection behavior, tab values, and the All count stay. Labels become native + English inline. + +## Key Insights + +- `LanguageRow` (today ~906–943) sets `aria-label` to `display_names.en` and lays out English at the start, native at the end, `justify-between`. Visible text and the accessible name both need to change. Drop the English-only `aria-label` so the name is the visible text. Tests use `/spanish/i`, `/korean/i`, `/english/i`, which still match. +- `getLanguageDisplayName` sorts by interface language. Leave it. Row text is a different rule. +- `ui/tabs.tsx` is only imported by this picker. Still override classes at the call site. Do not edit the primitive. +- `allLanguagesTab` already interpolates `totalLanguages`. Do not pass a literal 72. +- Q1: selected row keeps `yv:bg-muted`. +- Q3: high-contrast pills. Q4: semantic tokens so `data-yv-theme` dark still works. + +## Requirements + +- Primary: `display_names[language.id] || display_names.en || language.id`. +- Secondary: `display_names.en` only when it differs from primary. Omit when equal (English). +- Both inline, start-aligned, gap between them. Not `justify-between`. Not pinned to the far end. +- Each name in ``. +- Row taller than `Item` `size="sm"`. Prefer `size="default"` plus vertical padding on this row only. Do not change `ui/item.tsx` defaults. +- Section headings (`regionalHeading`, `allLanguagesHeading`, and the search-view Regional heading) step up from `text-lg` to `text-xl` `font-bold`. One size for all three. +- Tabs: Suggested and All. Same values `suggested` | `all`. All label still `t('allLanguagesTab', { count: totalLanguages })`. +- Active pill: high contrast against the list (`bg-foreground text-background` or the closest existing token pair). Inactive: muted. Full-width list stays. Bold active state can stay. +- Selected language row: `yv:bg-muted` plus hover. Unselected: hover only. +- Search results use the same `LanguageRow`. + +## Architecture + +Helper next to the row, not the sort helper: + +``` +function languageRowNames(language): { primary: string; secondary?: string } +``` + +Use it in `language-row.tsx` only. The versions entry row (phase 02) may call it for the selected language so the name matches. Do not use it to sort `uniqueLanguages`. + +Pill classes: `className` on `TabsList` / `TabsTrigger` inside `language-panel.tsx`. + +## Related Code Files + +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/bible-version-picker/language-row.tsx` +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/bible-version-picker/language-panel.tsx` +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/ui/tabs.tsx` (read only) +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/ui/item.tsx` (read only) +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/bible-version-picker.test.tsx` + +## Implementation Steps + +1. Add `languageRowNames`. Primary native, secondary English when different. +2. Rebuild `LanguageRow` content: one start-aligned row, primary then secondary. Remove `justify-between` and the English `aria-label`. +3. Increase row height via item size/padding on this component only. +4. Bump the three section headings to `text-xl`. +5. Override tab classes for the high-contrast active pill. Check light and dark (`data-yv-theme`) in stories. +6. Leave tab state, `onValueChange`, and count argument alone. + +## Todo + +- [ ] Native primary, English secondary, inline +- [ ] English-only rows have no duplicate secondary +- [ ] Selected row still `bg-muted` +- [ ] Taller rows, larger headings +- [ ] High-contrast pills, same tab behavior +- [ ] All count still `totalLanguages` +- [ ] `ui/tabs.tsx` and `ui/item.tsx` untouched + +## Success Criteria + +- Spanish row reads like `Español Spanish` on one side, not English at the start and Spanish at the far end. +- English row reads `English` once. +- Korean search result uses the same layout. +- Suggested / All still switch lists. All shows the live count from fixtures (3 languages in the unit fixture, not 72). +- Active pill is visibly stronger than today's `bg-background` on `bg-muted`, in light and dark. +- Clicking the selected language still fires `onLanguageChange` and closes. + +## Risk + +- RTL: inline order must follow `dir`, not hardcoded `margin-left`. Use `gap` and logical direction. Names stay `dir=auto` inside `bdi`. +- High-contrast `bg-foreground` on dark theme can invert too hard. Use semantic tokens only. If contrast fails, tweak the class, do not add a hex. +- Removing `aria-label` changes the accessible name from English-only to both strings. Regex tests should still pass. A test that expects the name to be exactly "Spanish" will fail. Update it to the visible string. + +## Security + +Names are API strings rendered as text. No `dangerouslySetInnerHTML`. No extra locale files. + +## Next Steps + +[phase-04-responsive-shell.md](phase-04-responsive-shell.md) (PR2). PR1 can merge after phase 06 without this phase. diff --git a/plans/20261022-1421-language-picker-v2/phase-04-responsive-shell.md b/plans/20261022-1421-language-picker-v2/phase-04-responsive-shell.md new file mode 100644 index 000000000..7b20ab446 --- /dev/null +++ b/plans/20261022-1421-language-picker-v2/phase-04-responsive-shell.md @@ -0,0 +1,116 @@ +# Phase 04 — Responsive shell + +## Context Links + +- [plan.md](plan.md) +- [research/researcher-01-figma-changelog.md](research/researcher-01-figma-changelog.md) item 7 +- [scout/scout-02-overlay-breakpoints.md](scout/scout-02-overlay-breakpoints.md) +- [reports/synthesis.md](reports/synthesis.md) +- [packages/ui/AGENTS.md](../../packages/ui/AGENTS.md) + +## Overview + +`sm+` stays the anchored card (~24rem, 66svh). Under `max-sm`, the SDK picker becomes a full-viewport sheet. Hosts that pass `open` / `onRequestClose` are unchanged. + +Ship as PR2. Fold into PR1 only after the phase 01 folder split, and only if review size is still sane. + +## Key Insights + +- Card size is already correct at `sm+`. Do not redesign it. +- `max-sm` today only sets `max-w-[calc(100vw-2rem)]`. +- Popover is shared with reader settings and verse actions. Defaults must keep the card and the X. +- Radix Popover positions with inline `top` / `left` / `transform` and has no overlay. A Tailwind `fixed inset-0` often loses. +- Q2 needs overlay-tap dismiss on mobile. Dialog already has an overlay. `dialog.tsx` itself must stay a centered card. +- Sheet, if added, is internal. Do not export it. + +## Requirements + +- Breakpoint: `max-sm` sheet, `sm+` card. Use Tailwind `max-sm:` / `sm:`. No JS `matchMedia` unless the CSS path is abandoned for a real Dialog mount. +- Sheet: full viewport (`inset-0`, `h-dvh`, `w-screen`, `max-w-none`, `max-h-none`, `rounded-none`). List scrolls inside. Header stays visible. +- Card: `w-sm`, `max-h-[66svh]`, picker `h-[66svh]`, `rounded-2xl`. Same as today. +- Overlay on the sheet. Tap overlay closes. Card keeps current outside-click close. No extra dim layer required on the card. +- Versions sheet: Close button remains (Q2 card Close, and a sheet X is the non-overlay exit). +- Languages and Search Languages: no header X at either width. Back is the exit. Overlay tap still closes the whole picker on mobile. +- Escape closes at both widths. +- `data-yv-theme` and `dir` still on the shell. +- Host body path: no sheet and no popover wrapped around `Content` when `onRequestClose` or `open` is set. + +## Architecture + +**Try this first (KISS):** `PopoverContent` props + +``` +sizeVariant?: 'card' | 'sheet' // default 'card' +showClose?: boolean // default true (added in phase 01) +``` + +Picker passes `sizeVariant="sheet"`. Implementation is CSS: + +- default / `card`: existing classes +- `sheet`: under `max-sm`, fixed full viewport; at `sm+` the card classes win +- sheet-only overlay sibling, `max-sm:block sm:hidden`, click closes + +**Stop and switch** if either is true after a manual check at a narrow viewport: + +1. The panel stays anchored to the trigger (Floating UI inline style wins). +2. Overlay-tap cannot close without breaking other popovers. + +**Fallback:** `packages/ui/src/components/ui/sheet.tsx` built on `@radix-ui/react-dialog` (copy the overlay pattern, not `DialogContent`'s centered card). Picker shell: + +- `sm+`: current `Popover` +- `max-sm`: `Sheet` + +Use `matchMedia('(max-width: 639px)')` only in this fallback, one listener, so we do not mount Popover and Dialog at once. Internal file. Not in `components/index.ts`. + +Do not edit `dialog.tsx`. + +## Related Code Files + +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/ui/popover.tsx` +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/ui/dialog.tsx` (read only) +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/bible-version-picker/index.tsx` +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/bible-reader.tsx` (confirm settings popover stays a card) +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/verse-action-popover.tsx` (same) +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/AGENTS.md` + +## Implementation Steps + +1. Add `sizeVariant` default `card` on `PopoverContent`. Picker opts into `sheet`. +2. Add sheet classes and a `max-sm` overlay that calls the popover close. Confirm other popovers omit the prop and look unchanged. +3. Open the vite example (`examples/vite-react`, env from repo root) at a narrow width and at `sm+`. +4. If the panel is not full-bleed or overlay tap does nothing, delete the CSS attempt's special cases (keep `showClose`) and add `ui/sheet.tsx`. Branch the picker shell on `max-sm`. +5. Re-check Versions Close, language views without Close, Back, Escape, overlay tap, and the host `onVersionPickerPress` path (no SDK shell). +6. Story for the sheet lives in phase 06. Add it here if this phase lands alone. + +## Todo + +- [ ] `sm+` card unchanged (24rem / 66svh) +- [ ] `max-sm` full viewport, scroll inside, header fixed +- [ ] Overlay tap closes on mobile +- [ ] Language views have no X; Versions still has X +- [ ] Other popovers unchanged +- [ ] Host `open` / `onRequestClose` path has no extra shell +- [ ] Sheet not exported if the fallback is used +- [ ] PR notes which path shipped + +## Success Criteria + +- At ≥640px the picker is the same card as before this phase. +- At <640px the picker covers the viewport. Overlay tap and Escape close it. Back still navigates views. +- Reader settings popover is still a card with Close at both widths. +- Sign-in dialog is still a centered card. + +## Risk + +- CSS sheet looks full-screen in a screenshot but ignores the trigger anchor and clips. The manual viewport check is the exit test, not a unit test. +- Mounting Dialog and Popover together doubles focus traps. Fallback mounts one. +- `100vw` includes scrollbar. Prefer `100%` / `inset-0` on a `fixed` layer. +- Safe area: add `padding-bottom: env(safe-area-inset-bottom)` on the sheet header/footer only if the vite example shows the search or Close under the home indicator. Do not add it speculatively on the card. + +## Security + +Modal focus trap must keep Escape and overlay dismiss. Do not set `modal={false}` on a full-screen layer. No new permissions. + +## Next Steps + +[phase-05-i18n-and-localization.md](phase-05-i18n-and-localization.md) can run parallel to PR1. [phase-06-tests-stories-changeset.md](phase-06-tests-stories-changeset.md) covers the sheet story. diff --git a/plans/20261022-1421-language-picker-v2/phase-05-i18n-and-localization.md b/plans/20261022-1421-language-picker-v2/phase-05-i18n-and-localization.md new file mode 100644 index 000000000..2aaedda44 --- /dev/null +++ b/plans/20261022-1421-language-picker-v2/phase-05-i18n-and-localization.md @@ -0,0 +1,92 @@ +# Phase 05 — i18n and localization + +## Context Links + +- [plan.md](plan.md) +- [docs/i18n-guidelines.md](../../docs/i18n-guidelines.md) +- [AGENTS.md](../../AGENTS.md) +- [scout/scout-01-picker-codebase.md](scout/scout-01-picker-codebase.md) +- [research/researcher-01-figma-changelog.md](research/researcher-01-figma-changelog.md) + +## Overview + +Reuse keys. Change one English value upstream. Wire two unused keys. Do not edit locale JSON in this repo. + +## Key Insights + +- Canonical English is `platform-localization` `sources/common/en.json` under the `react.*` prefix. This repo receives JSON from the Distribute React Localization workflow. Authors must be `platform-localization-pr-bot`. A human edit to `packages/ui/src/i18n/locales/**` fails CI. +- `selectLanguageHeading` is `"Select a Language"`. Changelog title is "Bible Languages". Value-edit that key. Do not add `bibleLanguagesHeading`. +- After the edit, that key is the Languages list title only. Search view title is existing `searchLanguages` ("Search languages"), unused in TS today. Using the heading key for both would show "Bible Languages" twice. +- `language` ("Language") is unused. It is the versions-row label (phase 02). +- `languageSearchAriaLabel` and `searchPlaceholder` already label both search fields. Keep them. +- `clearSearch` stays unused. No clear button in the changelog. +- `selectLanguageAriaLabel` stays on the public `BibleVersionPickerLanguageTrigger`. The in-picker row uses visible `language` text instead. Do not change the trigger's aria string in localization. +- Tests that look for "Select a Language" stay true until the sync PR. Do not assert "Bible Languages" in this repo before `en.json` updates. + +## Requirements + +- UI calls `t('selectLanguageHeading')` for the Languages title (already does). +- UI calls `t('searchLanguages')` for the Search Languages title. +- UI calls `t('language')` for the versions entry label. +- Search field keeps `t('languageSearchAriaLabel')` and `t('searchPlaceholder')`. +- Version field keeps `t('versionSearchAriaLabel')` and `t('searchPlaceholder')`. +- No new keys. +- No edits under `packages/ui/src/i18n/locales/`. +- Parallel PR in platform-localization: + - key `react.selectLanguageHeading` + - value `Bible Languages` + - `npm run generate && npm run validate` +- Translators update other locales via that repo. Do not paste translations into this repo. + +## Architecture + +Copy lives in components. This phase is a string-key checklist plus the upstream PR. It does not introduce an i18n wrapper. + +Visible title lags the Figma string until sync. Ship the UI PR anyway. Mention the lag in the PR body and link the localization PR. + +## Related Code Files + +- `/Users/cameron/Code/localization/platform-sdk-react/docs/i18n-guidelines.md` +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/i18n/locales/en.json` (read only) +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/bible-version-picker/index.tsx` +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/bible-version-picker/language-panel.tsx` +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/bible-version-picker/version-list.tsx` +- platform-localization `sources/common/en.json` (other repo, `react.selectLanguageHeading`) + +## Implementation Steps + +1. Grep the picker folder. Languages title = `selectLanguageHeading`. Search title = `searchLanguages`. Row label = `language`. No raw "Bible Languages" or "Search languages" in JSX. +2. Open the localization repo. Change only that value. Generate and validate. +3. Open the localization PR. Link it from the UI PR. +4. When the bot sync lands here, re-read `en.json`. Heading string should be "Bible Languages". Tests that hardcode the old heading get updated in that sync PR or a follow-up, not by editing JSON in the feature PR. + +## Todo + +- [ ] No locale JSON diff in the UI PR +- [ ] `searchLanguages` used as search-view title +- [ ] `language` used as row label +- [ ] Existing search aria/placeholder keys reused +- [ ] `clearSearch` still unused +- [ ] Upstream value-edit PR open for `react.selectLanguageHeading` +- [ ] UI PR links the localization PR and states the string lag + +## Success Criteria + +- UI PR diff has zero files under `packages/ui/src/i18n/locales/`. +- Search view heading function is `t('searchLanguages')`. +- Versions row shows the `language` key, not a literal. +- Localization PR contains the single value change to "Bible Languages" plus generated output that repo expects. + +## Risk + +- Value-edit lands before the UI PR and changes the heading with no layout change. Harmless. +- Someone "fixes" the lag by editing `en.json` here. CI locale-ownership will fail. Revert that hunk. +- Reusing `selectLanguageHeading` for the search title becomes wrong the day the value-edit merges. Do not do it. + +## Security + +No secrets in strings. No locale files from untrusted paste. Follow the bot sync path in `docs/i18n-guidelines.md`. + +## Next Steps + +[phase-06-tests-stories-changeset.md](phase-06-tests-stories-changeset.md) diff --git a/plans/20261022-1421-language-picker-v2/phase-06-tests-stories-changeset.md b/plans/20261022-1421-language-picker-v2/phase-06-tests-stories-changeset.md new file mode 100644 index 000000000..85a0852ab --- /dev/null +++ b/plans/20261022-1421-language-picker-v2/phase-06-tests-stories-changeset.md @@ -0,0 +1,102 @@ +# Phase 06 — Tests, stories, changeset + +## Context Links + +- [plan.md](plan.md) +- [docs/testing.md](../../docs/testing.md) +- [docs/release-hardening-decisions.md](../../docs/release-hardening-decisions.md) Decision 4 +- [AGENTS.md](../../AGENTS.md) +- [scout/scout-01-picker-codebase.md](scout/scout-01-picker-codebase.md) + +## Overview + +Update unit tests and stories to the new flow. Add a minor changeset on PR1 and a patch changeset on PR2. Do not assert the upstream "Bible Languages" string before sync. + +## Key Insights + +- Helpers in `bible-version-picker.test.tsx`: `openLanguagePanel` clicks `/select a language/i`. After phase 02 that control is the row (`language` + name), not the pill. Point the helper at the row. +- `getLanguageSearchInput` / `getVersionSearchInput` use accessible names. They survive moves. They fail if the language field is unmounted. Open the search view before querying it. +- These tests encode the old bottom filter and the crossfade. Update them. Do not restore old UI. + - `bounds and animates the default language view` expects 2 animated panels and a `.yv:relative` viewport. + - `open=false clears language search` types into the field immediately on standalone content. + - `preserves the selected tab after clearing language search` expects clearing the input to restore tabs. New behavior: empty query on the search view shows Regional; Back restores tabs and the selected tab. + - RTL test expects the language textbox on the languages view. Open search first. Still expect `dir=auto` on the field and `dir=rtl` on the dialog. +- Standalone language test expects heading "Select a Language" absent. Keep that. The heading is popover chrome. +- All-count assertions must use fixture length (`mockLanguages` that have versions), never 72 or 2308. +- Stories ~581 lines. `LanguageSearch` must follow header icon → field. Add a `max-sm` story only with phase 04. +- Changeset required. Empty changeset is wrong: this ships user-visible UI. Level: PR1 **minor**, PR2 **patch**. Package: `@youversion/platform-react-ui` only. +- `pnpm lint` is oxlint. Do not suppress anti-slop rules. + +## Requirements + +- Unit: `pnpm --filter @youversion/platform-react-ui exec vitest run --project unit src/components/bible-version-picker.test.tsx` +- Story play: `pnpm --filter @youversion/platform-react-ui exec vitest run --project storybook src/components/bible-version-picker.stories.tsx` +- Cover: three-view navigation, no bottom language field, top version search, no header pill, native+English row, selected `bg-muted`, All count from data, search empty state, `open=false` reset, `onVersionPickerPress` still skips the shell, RTL `dir`. +- PR2 adds a story that sets a narrow viewport and expects a full-bleed shell. Skip that story on PR1. +- Changeset files under `.changeset/`. No hand-written changelog. +- Do not edit locale JSON to make a heading assertion pass. + +## Architecture + +Keep tests next to the old path: + +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/bible-version-picker.test.tsx` +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/bible-version-picker.stories.tsx` + +Imports stay `./bible-version-picker` after the folder move. + +Assert roles and names, not crossfade class strings. Replace the 2-panel query with: languages heading present, version list absent (or `hidden` not in tree), and the reverse after Back. + +## Related Code Files + +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/bible-version-picker.test.tsx` +- `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/bible-version-picker.stories.tsx` +- `/Users/cameron/Code/localization/platform-sdk-react/docs/testing.md` +- `/Users/cameron/Code/localization/platform-sdk-react/docs/release-hardening-decisions.md` +- `/Users/cameron/Code/localization/platform-sdk-react/.changeset/README.md` +- `/Users/cameron/Code/localization/platform-sdk-react/examples/vite-react` (manual card check; env from repo root) + +## Implementation Steps + +1. Update helpers: open languages via the row; open search via the icon; then use the existing textbox getters. +2. Rewrite the tests listed in Key Insights. Keep behavior tests that should not change (recent cap, usable versions, version select calls `onRequestClose`, trigger `preventDefault`). +3. Add one test that Spanish/Korean row text is native then English inline (fixture `display_names` already has `es` / `ko`). +4. Add one test that All tab text includes `String(total)` from the rendered fixture, not a hardcoded design count. +5. Update `LanguageSearch` and `InteractiveVersionSearch` stories so `play` uses the new entry points. +6. PR2 only: story with mobile viewport, full-bleed shell, overlay present. +7. `pnpm changeset` — minor on PR1, patch on PR2. Summary says why (overlay matches the v2 language changelog; mobile shell). +8. `pnpm lint` on the touched package. Fix by editing code, not by disabling rules. +9. Browser: `examples/vite-react` picker. Walk Versions → Languages → Search → Back → Back. Then a narrow window when PR2 exists. Desktop and a sub-640 width. This is required for UI work; a story screenshot is not enough. + +## Todo + +- [ ] Unit file green, including updated search and animation tests +- [ ] Row label test (native + English) +- [ ] All count from fixture data +- [ ] Storybook play green +- [ ] PR2 mobile story, if that PR exists +- [ ] Minor changeset (PR1) and patch changeset (PR2) +- [ ] Lint clean +- [ ] Vite demo walked on desktop; mobile walked if phase 04 is in the branch + +## Success Criteria + +- Both vitest commands above pass. +- Diff does not include `packages/ui/src/i18n/locales/**`. +- Changeset status is not empty. +- Demo: no bottom search, language row opens Languages, search icon opens a focused field, Back stack is search → languages → versions, selected language stays highlighted. +- PR2 demo: narrow viewport is full screen; wide viewport is the card. + +## Risk + +- Asserting "Bible Languages" fails until sync. Assert the heading role via the current `en.json` string or via the key's rendered output without hardcoding the future value. +- Story `play` that still types into a bottom field will time out. Update stories in the same PR as the UI. +- Folder move can leave the story or test importing a deleted path. Both should use `./bible-version-picker`. + +## Security + +Tests use fixtures. Do not point the demo at a new auth scope. Navbar sign-in stays profile and email. No highlight grant changes in this work. + +## Next Steps + +PR1: phases 01–03 + this phase, minus the mobile story. PR2: phase 04 + mobile story. Localization PR from phase 05 in parallel. Stop. Do not start download/audio frames. diff --git a/plans/20261022-1421-language-picker-v2/plan.md b/plans/20261022-1421-language-picker-v2/plan.md new file mode 100644 index 000000000..b5ba60e39 --- /dev/null +++ b/plans/20261022-1421-language-picker-v2/plan.md @@ -0,0 +1,36 @@ +# Language Picker v2 + +Match Figma Languages Overlay v2 (2026-10-22). UI structure only. No download, audio, or font work. + +**Source:** [Languages Overlay Current vs v2](https://www.figma.com/design/temXvWs3FR8DZdM8C17ajf/YVP---Reader-SDK-Design-v2?node-id=4966-6915) node `4966:6915`. + +**Standards:** [AGENTS.md](../../AGENTS.md), [packages/ui/AGENTS.md](../../packages/ui/AGENTS.md), [docs/i18n-guidelines.md](../../docs/i18n-guidelines.md), [docs/testing.md](../../docs/testing.md), [docs/release-hardening-decisions.md](../../docs/release-hardening-decisions.md) Decision 4. This repo has no `docs/development-rules.md`. + +## Decisions + +- Ship changelog items 1–7. Detail in [reports/synthesis.md](reports/synthesis.md). +- Q1–Q5 are assumptions: keep highlight, Back plus card Close plus sheet overlay dismiss, high-contrast pills, existing `data-yv-theme`, API counts only. +- Language rows: native primary + English secondary, inline. Current English-first `justify-between` is the bug. +- Item 7: try `PopoverContent` `sizeVariant` `card|sheet` first. Internal Sheet on Radix Dialog if Floating UI blocks full-bleed. PR2. +- PR1 = phases 01–03 + popover tests + **minor** changeset. Works in today's card. +- Phase 01 splits the 1103-line picker. Later phases edit the new modules. +- Copy changes land in platform-localization. Do not hand-edit `locales/*.json`. + +## Phases + +- [ ] [01 Panel state + search view](phase-01-panel-state-and-search.md) — 3 views, header search, drop bottom language filter, extract modules +- [ ] [02 Versions entry + search](phase-02-versions-entry-and-search.md) — top search, language row, hide header pill, drop bottom version search +- [ ] [03 Language rows + tabs](phase-03-language-rows-and-tabs.md) — native+English inline, taller rows, high-contrast pills +- [ ] [04 Responsive shell](phase-04-responsive-shell.md) — card at `sm+`, full-viewport under `max-sm` (PR2) +- [ ] [05 i18n](phase-05-i18n-and-localization.md) — reuse keys, upstream value-edit, wire `language` + `searchLanguages` +- [ ] [06 Tests, stories, changeset](phase-06-tests-stories-changeset.md) + +01–03 and the popover half of 06 ship as PR1. 04 plus sheet stories ship as PR2. Fold 04 into PR1 only after the phase 01 file split. 05 is a sibling-repo PR. UI code calls existing keys, so English stays the current `en.json` until sync. + +## Out of scope + +Download, audio, edit-recent frames. Invented region glosses. Aktiv Grotesk. Locale-file edits. Public prop removals. A made-up versions subtitle. + +## Unresolved + +Q1–Q5 are locked assumptions. Leftover questions: [reports/synthesis.md](reports/synthesis.md#unresolved-questions). diff --git a/plans/20261022-1421-language-picker-v2/reports/synthesis.md b/plans/20261022-1421-language-picker-v2/reports/synthesis.md new file mode 100644 index 000000000..bdad30670 --- /dev/null +++ b/plans/20261022-1421-language-picker-v2/reports/synthesis.md @@ -0,0 +1,77 @@ +# Synthesis — Language Picker v2 + +## Intent + +Bring the Bible version/language overlay in line with Figma node `4966:6915` (2026-10-22 changelog). Behavior of tabs, search matching, recent versions, and YPE-4657 usable-version filtering stays. Chrome and information architecture change. + +## Match plan + +| # | Change | Phase | +| --- | --- | --- | +| 1 | Languages header: Bible Languages, search icon, Back, no Close | 01, 05 | +| 2 | Dedicated Search Languages view; delete bottom language filter | 01 | +| 3 | High-contrast Suggested / All; count from `totalLanguages` | 03 | +| 4 | Taller rows, larger section heading, keep `bg-muted` selection | 03 | +| 5 | Native + English inline | 03 | +| 6 | Versions: top search, full-width language row, no pill, no bottom search, no invented subtitle | 02 | +| 7 | `max-sm` sheet, `sm+` card | 04 | + +## Architecture + +Replace `isLanguagesOpen: boolean` with internal `PickerView = 'versions' | 'languages' | 'languageSearch'`. Mount one view. Do not keep the two-layer blur crossfade (it cannot represent three views and duplicates focus targets). + +``` +versions + header: Bible Versions + Close (card only) + body: version search, language row, recent, all +languages + header: Back + Bible Languages + search icon + body: Suggested | All pills, lists +languageSearch + header: Back + Search languages + body: autofocus field, Regional until query, then results +``` + +Standalone `BibleLanguagePickerContent` has no popover header. Context flag `headerOwnsChrome` (true only in the SDK popover branch) tells the language module to skip its own search button. Standalone draws that button itself so search still exists. No new public prop. + +File split in phase 01, before feature edits stack into the 1103-line file: + +``` +packages/ui/src/components/bible-version-picker/ + index.tsx # Root, Trigger, context, compound export + version-list.tsx # versions body + language-panel.tsx # languages + search views + language-row.tsx +``` + +Delete flat `bible-version-picker.tsx` so the specifier `./bible-version-picker` resolves to the folder. Keep tests and stories where they are. + +Item 7 decision: add optional `sizeVariant: 'card' | 'sheet'` and `showClose?: boolean` on `PopoverContent`, both defaulting to current behavior (`card`, close shown). Sheet classes apply under `max-sm` only. If a spike shows Floating UI inline style beating `fixed inset-0`, or overlay-tap cannot exist without a dialog, stop and add internal `ui/sheet.tsx` on `@radix-ui/react-dialog`. Do not change `dialog.tsx`. Do not export Sheet. + +## PR split + +- **PR1 (minor, `@youversion/platform-react-ui`):** phases 01–03 + unit tests updated for the new flow + stories that still fit the card. One changeset. Public props unchanged. +- **PR2 (patch):** phase 04 + a mobile story. Fold into PR1 only after the folder split, and only if the diff stays reviewable. +- **Localization PR (platform-localization):** value-edit `react.selectLanguageHeading` to "Bible Languages". No new keys required for the locked copy. Do not touch `packages/ui/src/i18n/locales/**` here (locale-ownership CI). + +Until that sync merges, `t('selectLanguageHeading')` still renders "Select a Language". That is expected. Do not assert "Bible Languages" in this repo first. + +## YAGNI + +- Do not wire `clearSearch`. No clear button in the changelog. +- Do not change `getLanguageDisplayName` sort order. +- Do not unique-ify the version count. +- Do not add a subtitle key. +- Do not restyle `ui/tabs.tsx` (only consumer today, but keep the primitive generic). Override classes at the call site. +- Do not preserve the crossfade to satisfy the 2-panel test. Update the test. + +## Unresolved questions + +- [ ] Versions header subtitle — Figma may show a total. No key exists. **Assumption: omit.** Add one only if design supplies copy via platform-localization. +- [ ] Badge sum double-counts a version that is both recent and in `filteredVersions`. **Assumption: keep the sum.** Do not change it in this work. +- [ ] Back from Search Languages clears the query and returns to the language list, preserving the Selected/All tab. **Assumption: yes.** Empty query while still on the search view shows Regional, not the tabs. This replaces the old "clear input restores tabs" behavior. +- [ ] Search Languages header has no Close, same as Languages. **Assumption: yes** (Q2). Versions card keeps Close. +- [ ] `sizeVariant` vs internal Sheet is an engineering exit, not a product question. Record the path that shipped in the PR2 description. +- [ ] Sheet/card wrapper stays off the host `open` / `onRequestClose` body. **Assumption: yes.** +- [ ] High-contrast pill colors are semantic tokens on `data-yv-theme`. If dark contrast fails, adjust tokens only. No new palette and no dark artboard (Q4). +- [ ] `BibleVersionPickerLanguageTrigger` stays exported for hosts and direct tests. The SDK header stops rendering it once the row exists. Confirm no in-repo reader header depends on the hidden-pill layout (reader passes the compound picker; the pill is inside `Content`). diff --git a/plans/20261022-1421-language-picker-v2/research/researcher-01-figma-changelog.md b/plans/20261022-1421-language-picker-v2/research/researcher-01-figma-changelog.md new file mode 100644 index 000000000..e052e143c --- /dev/null +++ b/plans/20261022-1421-language-picker-v2/research/researcher-01-figma-changelog.md @@ -0,0 +1,34 @@ +# Researcher 01 — Figma changelog + +## Source + +- File: YVP — Reader SDK Design v2 +- Node: `4966:6915` (Languages Overlay Current vs v2, new 2026-10-22) +- URL: https://www.figma.com/design/temXvWs3FR8DZdM8C17ajf/YVP---Reader-SDK-Design-v2?node-id=4966-6915 +- This plan did not re-fetch pixels. Match list below is the locked brief for that node. Spot-check the node only when a spacing step is ambiguous. + +Prior "fonts only" scope is void. The changelog is the spec. + +## Match items + +1. **Languages header.** Title "Bible Languages". Search icon. No Close on the Languages view. Back returns to Versions. +2. **Search Languages view.** Opened from that header icon. Field at the top. Regional list until the query is non-empty. Autofocus the field. Back returns to the language list. Remove the bottom language search filter. +3. **Suggested / All pills.** Restyle to high-contrast. Behavior unchanged. `All ({{count}})` stays API-driven. +4. **Rows.** Taller language rows. Larger section heading. Keep the selected-language highlight (Q1 = yes). +5. **Labels.** Data pattern stays native + English. Visual is inline, not far-end. See researcher 02. Current code is English-first `justify-between` and does not match the mocks. +6. **Versions.** Full-width Language row (globe / Language / name / count / chevron) under a top version search. Remove the bottom version search. Remove or hide the header language pill once the row exists. Subtitle totals only when a real string already exists. Do not invent one. +7. **Responsive.** `max-sm` ≈ mobile → full-viewport sheet. `sm+` keeps the popover card (~24rem / 66svh). + +## Explicit non-matches + +Do not build frames for download, audio, or edit-recent. Do not add region nickname glosses. Do not load Aktiv Grotesk. Do not hardcode mock counts 2308 or 72. + +## Header matrix + +| View | Title key | Leading | Trailing | Close | +| --- | --- | --- | --- | --- | +| Versions | `bibleVersionsHeading` | none | none (pill goes away in phase 02) | yes on card | +| Languages | `selectLanguageHeading` → "Bible Languages" after upstream edit | Back → versions | search icon | no | +| Search Languages | `searchLanguages` ("Search languages") | Back → language list | none | no | + +Search view does not reuse `selectLanguageHeading` after the value-edit. That key becomes the list title. `searchLanguages` is the distinct unused key. diff --git a/plans/20261022-1421-language-picker-v2/research/researcher-02-matchability-and-defaults.md b/plans/20261022-1421-language-picker-v2/research/researcher-02-matchability-and-defaults.md new file mode 100644 index 000000000..1b556c3d5 --- /dev/null +++ b/plans/20261022-1421-language-picker-v2/research/researcher-02-matchability-and-defaults.md @@ -0,0 +1,52 @@ +# Researcher 02 — matchability and defaults + +## What already matches + +- Suggested / All tabs exist. `allLanguagesTab` is `All ({{count}})`. `totalLanguages` is `uniqueLanguages.length`. +- Regional block is `suggestedLanguages` (browser languages, then `useLanguages({ country: 'zz' })`), not a hand-written region list. +- Selected row already uses `yv:bg-muted` when `language.id === selectedLanguageId`. +- Light/dark is `data-yv-theme` on popover and language content. No second palette. +- Version and language search already filter client-side. Queries are not a new API. +- Card metrics already match item 7 at `sm+`: `PopoverContent` is `w-sm` (24rem) and `max-h-[66svh]`. Picker also sets `h-[66svh]`. +- `GlobeIcon`, `SearchIcon`, `ChevronForwardIcon`, `ChevronBackwardIcon` exist. +- Hosts can skip the popover via `onVersionPickerPress` and render `Content` / `BibleLanguagePickerContent` with `open` + `onRequestClose`. + +## What does not match + +- One boolean `isLanguagesOpen`. No third view. +- Bottom search on both panels. Languages filter in place and hides tabs while `languageSearchQuery` is non-empty. +- Header language control is `BibleVersionPickerLanguageTrigger` (pill + badge), not a full-width row. +- `LanguageRow` puts `display_names.en` on the start and `display_names[id]` on the end with `justify-between`. +- `LanguageRow` `aria-label` is English only. +- Popover header always renders `PopoverClose`. No `showClose` prop. +- `max-sm` only narrows the card (`max-w-[calc(100vw-2rem)]`). No sheet, no overlay. +- `language` and `searchLanguages` are in `en.json` and unused by TS/TSX. +- Sort helper `getLanguageDisplayName` uses `display_names[selectedLanguageId]`, then `en`, then `id`. That is interface-language sort, not the row's native label. Do not reuse it for row text. + +## Label rule (item 5) + +``` +primary = display_names[id] || display_names.en || id +secondary = display_names.en when it differs from primary +layout = both inline, start-aligned, small gap +``` + +English-only languages omit the secondary. Keep `` on each name. + +## Locked assumptions + +| ID | Assumption | +| --- | --- | +| Q1 | Keep active-language highlight (`yv:bg-muted`). | +| Q2 | Back is the language-view exit. Keep `PopoverClose` on the Versions card (`sm+`). Mobile sheet dismisses on overlay tap. No Close on Languages or Search Languages. | +| Q3 | High-contrast pills from the v2 hybrid frames. Override classes on this picker only. | +| Q4 | Ship light and dark through `data-yv-theme`. No Figma dark artboard required. | +| Q5 | Counts from data: `totalLanguages`, `filteredVersions.length`, existing badge sum. Never hardcode 2308/72. | + +## Count caveat + +Header badge today is `filteredVersions.length + filteredRecentVersions.length`. A recent version can also sit in `filteredVersions`, so the sum can double-count. Move that same number onto the new row. Do not invent a unique-count fix here. + +## Subtitle + +No existing key is a versions total line. `allVersionsHeading` is the section title "All Versions". Omit the header subtitle. Do not add a key in this repo. diff --git a/plans/20261022-1421-language-picker-v2/scout/scout-01-picker-codebase.md b/plans/20261022-1421-language-picker-v2/scout/scout-01-picker-codebase.md new file mode 100644 index 000000000..d889d7c83 --- /dev/null +++ b/plans/20261022-1421-language-picker-v2/scout/scout-01-picker-codebase.md @@ -0,0 +1,62 @@ +# Scout 01 — picker codebase + +## Summary + +One file, 1103 lines. Compound `Root` / `Trigger` / `Content`. `LanguagePanel` crossfades over versions. Both panels keep a bottom search. `LanguageRow` is English-first `justify-between`. Header hosts the language pill. No Sheet in the SDK. Keys `language`, `searchLanguages`, `clearSearch` are unused. Tests ~719 lines. Stories ~581 lines. Usable versions follow YPE-4657. i18n is platform-localization, not hand-edited JSON. + +## File + +`/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/bible-version-picker.tsx` + +Public barrel: `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/index.ts` re-exports `BibleVersionPicker`, `BibleLanguagePickerContent`, `BibleVersionPickerLanguageTrigger`, and the public prop types. + +## Map + +| Symbol | Lines (approx) | Role | +| --- | --- | --- | +| `RECENT_VERSIONS_KEY`, recent storage | 49–106 | local recent list, max 3 | +| `filterLanguagesBySearch` | 112–124 | id, `language`, any `display_names` value | +| `Root` | 294–518 | context, data hooks, popover owner | +| `Trigger` | 534–593 | version button or `onVersionPickerPress` | +| `BibleVersionPickerLanguageTrigger` | 603–659 | pill: globe, English name, badge | +| `Content` | 661–894 | two modes, see below | +| `LanguagePanel` | 896–904 | shell around language content | +| `LanguageRow` | 906–943 | English start, native end | +| `BibleLanguagePickerContent` | 951–1100 | tabs or in-place search, bottom field | + +## Content is two modes in one function + +- No `open` and no `onRequestClose`: render `PopoverContent` and **call `Content` again** with `onRequestClose`. Inner call takes the body branch. Not infinite. +- Otherwise: versions list + bottom search. This is the host/standalone body. +- `onVersionPickerPress` without `open` / `onRequestClose`: return null. Host draws its own UI. + +Popover branch crossfades two absolute layers (`isLanguagesOpen`). Test `bounds and animates the default language view` expects **2** nodes with `transition-all duration-300`. + +## State already in context + +`isLanguagesOpen`, `languageSearchQuery`, `searchQuery`, `filteredLanguages`, `suggestedLanguages`, `uniqueLanguages` as `languages`, `totalLanguages`, `filteredVersions`, `filteredRecentVersions`, `closeLanguagePanel` (clears language query and closes), `resetLanguageSearch`. + +Data: `useLanguages` (all + `country: 'zz'`), `useVersions(selectedLanguageId)`, `useVersions('*')` for language tags, `useFilteredVersions`, `isUsableBibleVersion` on recents (YPE-4657). UI must not call core clients. Hooks only. + +## Header today + +`PopoverContent` heading swaps `selectLanguageHeading` / `bibleVersionsHeading`. Back chevron only when languages are open (`backToBibleVersionsAriaLabel`). `headerChild` is `BibleVersionPickerLanguageTrigger`, visibility hidden while languages are open. Close always comes from popover, not this file. + +## i18n keys this file actually calls + +`selectLanguageHeading`, `bibleVersionsHeading`, `backToBibleVersionsAriaLabel`, `recentlyUsedVersionsHeading`, `allVersionsHeading`, `noVersionsFound`, `versionSearchAriaLabel`, `searchPlaceholder`, `suggestedTab`, `allLanguagesTab`, `regionalHeading`, `allLanguagesHeading`, `noLanguageSearchResults`, `noRegionalLanguagesAvailable`, `languageSearchAriaLabel`, `selectLanguageAriaLabel`, `select`. + +Unused in TS/TSX, present in `en.json`: `language` ("Language"), `searchLanguages` ("Search languages"), `clearSearch` ("Clear search"). + +## Tests that encode today's chrome + +`/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/bible-version-picker.test.tsx` + +- `openLanguagePanel` clicks `/select a language/i` (the pill). +- `getLanguageSearchInput` / `getVersionSearchInput` use accessible names, so a moved field still matches if the aria-label stays. +- Standalone language content asserts the heading **Select a Language** is absent (heading is popover-only). +- `open=false` clears either search on pre-warmed standalone content. +- Language search expects: typing hides tabs, clear restores the selected tab, back to versions clears the query. +- RTL test expects `dir=rtl` on dialog and tablist parent, `dir=auto` on the language field and name `bdi`. + +Stories: `/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/bible-version-picker.stories.tsx` (`LanguageSearch`, `SuggestedLanguagesTabs`, `InteractiveVersionSearch`, light/dark). diff --git a/plans/20261022-1421-language-picker-v2/scout/scout-02-overlay-breakpoints.md b/plans/20261022-1421-language-picker-v2/scout/scout-02-overlay-breakpoints.md new file mode 100644 index 000000000..fe94e13fa --- /dev/null +++ b/plans/20261022-1421-language-picker-v2/scout/scout-02-overlay-breakpoints.md @@ -0,0 +1,42 @@ +# Scout 02 — overlay and breakpoints + +## Summary + +Picker chrome is only the popover card. `Dialog` is a centered small card. No Sheet or Drawer. `max-sm` only shrinks width. `onVersionPickerPress` is the host escape hatch. `Content` `open` / `onRequestClose` is the host-owned wrapper path. + +## Popover + +`/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/ui/popover.tsx` + +- Radix Popover. Portal. No overlay. +- Classes: `w-sm`, `sm:max-w-sm`, `max-sm:max-w-[calc(100vw-2rem)]`, `max-h-[66svh]`, `rounded-2xl`, `shadow-lg`. +- Floating UI sets position inline. `collisionPadding={16}`. Side slide animations. Transform origin `--radix-popover-content-transform-origin`. +- Header grid grows columns for `headerLeading` and `headerChild`. +- `PopoverClose` + `XIcon` always render when `showHeader` is true. No prop to hide Close alone. +- `theme` → `data-yv-theme`. `dir` from `useInterfaceDirection`. +- Shared. Also used by reader settings and verse actions in `bible-reader.tsx`. New props must default to today's card. + +## Dialog + +`/Users/cameron/Code/localization/platform-sdk-react/packages/ui/src/components/ui/dialog.tsx` + +- Radix Dialog. Has overlay `fixed inset-0 bg-black/50`. +- Content is centered: `left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2`, `max-w-sm`, `rounded-2xl`, `p-6`. +- Do not restyle this for the picker. Sign-in and highlight dialogs use the centered card. + +## Why CSS-only full-bleed is the risky first try + +A `max-sm:fixed max-sm:inset-0` class fights Floating UI `top` / `left` / `transform`. Popover also has no overlay, and Q2 wants overlay-tap dismiss on mobile. Click-outside already closes a popover, but there is no dimmed layer. + +## Breakpoint contract + +- `max-sm` (under 640px): full viewport. Square corners. Own overlay. Scroll inside the sheet. +- `sm+`: current card. ~24rem wide. `66svh`. Anchored popover. Versions keep Close. Language views do not. + +## Host path must stay a body + +When the host passes `onVersionPickerPress` and renders `Content` / `BibleLanguagePickerContent` itself, this SDK must not wrap that body in a second popover or sheet. The host owns the shell. Sheet/card switching applies only to the SDK popover branch (`open` and `onRequestClose` both absent). + +## Primitives rule + +`packages/ui/AGENTS.md`: `src/components/ui/` primitives are internal except `Separator` and `Textarea`. A new Sheet stays unexported from `src/index.ts` / `components/index.ts`. diff --git a/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/compare-01-versions.png b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/compare-01-versions.png new file mode 100644 index 000000000..ed7adefc0 Binary files /dev/null and b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/compare-01-versions.png differ diff --git a/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/compare-02-languages.png b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/compare-02-languages.png new file mode 100644 index 000000000..fa4870a68 Binary files /dev/null and b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/compare-02-languages.png differ diff --git a/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/compare-03-search.png b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/compare-03-search.png new file mode 100644 index 000000000..879827d7b Binary files /dev/null and b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/compare-03-search.png differ diff --git a/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/figma-01-versions.png b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/figma-01-versions.png new file mode 100644 index 000000000..55a4d5599 Binary files /dev/null and b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/figma-01-versions.png differ diff --git a/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/figma-02-languages.png b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/figma-02-languages.png new file mode 100644 index 000000000..d71c08748 Binary files /dev/null and b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/figma-02-languages.png differ diff --git a/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/figma-03-search.png b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/figma-03-search.png new file mode 100644 index 000000000..6638b6ebc Binary files /dev/null and b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/figma-03-search.png differ diff --git a/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/new-01-versions.png b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/new-01-versions.png new file mode 100644 index 000000000..6886785f3 Binary files /dev/null and b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/new-01-versions.png differ diff --git a/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/new-02-languages.png b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/new-02-languages.png new file mode 100644 index 000000000..9887d51a2 Binary files /dev/null and b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/new-02-languages.png differ diff --git a/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/new-03-search.png b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/new-03-search.png new file mode 100644 index 000000000..6970c1ea7 Binary files /dev/null and b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/new-03-search.png differ diff --git a/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/old-01-versions.png b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/old-01-versions.png new file mode 100644 index 000000000..786fc663c Binary files /dev/null and b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/old-01-versions.png differ diff --git a/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/old-02-languages.png b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/old-02-languages.png new file mode 100644 index 000000000..3d167b3ea Binary files /dev/null and b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/old-02-languages.png differ diff --git a/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/old-03-search.png b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/old-03-search.png new file mode 100644 index 000000000..d6f67e238 Binary files /dev/null and b/plans/20261022-1421-language-picker-v2/screenshots/compare-2026-10-30/old-03-search.png differ diff --git a/plans/20261022-1421-language-picker-v2/screenshots/figma-01-versions-overlay-4966-10478.png b/plans/20261022-1421-language-picker-v2/screenshots/figma-01-versions-overlay-4966-10478.png new file mode 100644 index 000000000..55a4d5599 Binary files /dev/null and b/plans/20261022-1421-language-picker-v2/screenshots/figma-01-versions-overlay-4966-10478.png differ diff --git a/plans/20261022-1421-language-picker-v2/screenshots/figma-02-languages-hybrid-4966-11367.png b/plans/20261022-1421-language-picker-v2/screenshots/figma-02-languages-hybrid-4966-11367.png new file mode 100644 index 000000000..d71c08748 Binary files /dev/null and b/plans/20261022-1421-language-picker-v2/screenshots/figma-02-languages-hybrid-4966-11367.png differ diff --git a/plans/20261022-1421-language-picker-v2/screenshots/figma-03-search-languages-hybrid-4966-9781.png b/plans/20261022-1421-language-picker-v2/screenshots/figma-03-search-languages-hybrid-4966-9781.png new file mode 100644 index 000000000..6638b6ebc Binary files /dev/null and b/plans/20261022-1421-language-picker-v2/screenshots/figma-03-search-languages-hybrid-4966-9781.png differ diff --git a/plans/20261022-1421-language-picker-v2/screenshots/live-01-versions-list-repo.png b/plans/20261022-1421-language-picker-v2/screenshots/live-01-versions-list-repo.png new file mode 100644 index 000000000..5ce9aabb7 Binary files /dev/null and b/plans/20261022-1421-language-picker-v2/screenshots/live-01-versions-list-repo.png differ diff --git a/plans/20261022-1421-language-picker-v2/screenshots/live-02-languages-panel-repo.png b/plans/20261022-1421-language-picker-v2/screenshots/live-02-languages-panel-repo.png new file mode 100644 index 000000000..d6fc4454e Binary files /dev/null and b/plans/20261022-1421-language-picker-v2/screenshots/live-02-languages-panel-repo.png differ diff --git a/plans/20261022-1421-language-picker-v2/screenshots/live-03-language-search-repo.png b/plans/20261022-1421-language-picker-v2/screenshots/live-03-language-search-repo.png new file mode 100644 index 000000000..335f824f8 Binary files /dev/null and b/plans/20261022-1421-language-picker-v2/screenshots/live-03-language-search-repo.png differ