Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d82676e
refactor(ui): split the Bible version picker into modules
camrun91 Oct 22, 2026
053de70
feat(ui): give the language picker one view at a time
camrun91 Oct 22, 2026
2fe156a
feat(ui): put version search and the language row at the top
camrun91 Oct 22, 2026
5f2377e
feat(ui): show native language names beside English
camrun91 Oct 22, 2026
6b49cb7
test(ui): cover the language picker card flow
camrun91 Oct 22, 2026
08ac220
fix(ui): keep webkit appearance through CSS watch
camrun91 Oct 22, 2026
3c24f09
docs: add language picker v2 plan and Figma comparison screenshots
camrun91 Oct 22, 2026
a11d66d
fix(ui): address Greptile feedback on language picker PR
camrun91 Oct 23, 2026
463d93d
fix(ui): allow ref on LanguageSearchButton for typecheck
camrun91 Oct 23, 2026
0d21d32
fix(ui): match language picker type and pill radii to Figma
camrun91 Oct 23, 2026
00e43dc
fix(ui): force language row secondary label size hierarchy
camrun91 Oct 23, 2026
ed1d79a
fix(ui): align versions list chrome with Figma overlay
camrun91 Oct 23, 2026
14b8d3c
fix(ui): stop version abbreviation tiles from jittering
camrun91 Oct 23, 2026
ac832d1
fix(ui): flatten picker header and show versions statistics
camrun91 Oct 23, 2026
68566d2
fix(ui): align Bible picker with tablet designs
camrun91 Oct 27, 2026
e0ee3d2
fix(ui): use a distinct Bible languages heading key
camrun91 Oct 27, 2026
27623d1
fix(ui): move picker verification into Storybook journeys
camrun91 Oct 28, 2026
d6792dc
Merge branch 'main' into feat/language-picker-v2
camrun91 Oct 29, 2026
9597673
Merge branch 'main' into feat/language-picker-v2
camrun91 Oct 29, 2026
2275fb6
fix(ui): wrap picker language names and re-query overflow dialog
camrun91 Oct 29, 2026
eb4702e
docs: add old/new/Figma language picker comparison screenshots
camrun91 Oct 30, 2026
7d903ac
fix(ui): restore picker focus and truncate long language names
camrun91 Oct 30, 2026
b69965c
chore(ci): raise ui tailwind.css size budget for language picker
camrun91 Oct 30, 2026
1892839
fix(ui): scope picker view-change focus to this popover
camrun91 Oct 30, 2026
1debf51
fix(ui): address language picker review on a11y and shared popover
camrun91 Oct 30, 2026
22393c9
fix(ui): match short-viewport height assert to Radix cap
camrun91 Oct 30, 2026
26aa856
test(ui): scope picker beforeEach to legacy cases only
camrun91 Oct 30, 2026
2a104c0
test(ui): move edited picker workflows off legacy beforeEach
camrun91 Nov 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/language-picker-v2-card.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/preserve-webkit-on-css-watch.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
];
6 changes: 3 additions & 3 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
9 changes: 7 additions & 2 deletions packages/ui/scripts/check-public-entries.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
21 changes: 21 additions & 0 deletions packages/ui/scripts/verify-styles.js
Original file line number Diff line number Diff line change
@@ -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)), '..');
Expand All @@ -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()) {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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) {
Expand Down
24 changes: 24 additions & 0 deletions packages/ui/scripts/watch-preserve-host-revert-layer.js
Original file line number Diff line number Diff line change
@@ -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);
});
160 changes: 143 additions & 17 deletions packages/ui/src/components/bible-version-picker.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -167,27 +292,27 @@ 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 });
await userEvent.click(koreanOption);

// 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(
/한국어/,
);
});
},
};
Expand Down Expand Up @@ -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
Expand All @@ -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 });
Expand All @@ -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
Expand All @@ -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,
);
});
},
};
Expand Down Expand Up @@ -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)
Expand All @@ -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$/);
},
};

Expand All @@ -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 });
Expand Down
Loading
Loading