Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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/ype-5119-provider-lng.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@youversion/platform-react-ui': minor
---

Add an optional `lng` prop on `YouVersionProvider` so a host can set UI language instead of always following the browser locale.
12 changes: 12 additions & 0 deletions docs/i18n-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ const { t } = useTranslation(undefined, { i18n });

Never hardcode user-facing text in JSX attributes (`aria-label`, `title`, `placeholder`, `alt`) or visible copy.

## Host-set language

By default the UI language follows `navigator.languages`. Pass `lng` on `YouVersionProvider` to set it explicitly — for example the React Native Expo SDK forwarding its `locale` into a WebView:

```tsx
<YouVersionProvider appKey="YOUR_APP_KEY" lng="es">
<VerseOfTheDay />
</YouVersionProvider>
```

Regional tags such as `es-MX` resolve to a bundled locale (`es`). Unsupported tags fall back to English. Omit `lng` to keep browser detection. No new translation keys are needed for this; existing bundles (including Spanish `verseOfTheDay`) are used as-is.

## Local checks

```bash
Expand Down
8 changes: 8 additions & 0 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ function App() {
}
```

Optional `lng` sets bundled UI copy (Verse of the Day heading, buttons, etc.) instead of following the browser language. Regional tags like `es-MX` resolve to a bundled locale:

```tsx
<YouVersionProvider appKey="YOUR_APP_KEY" lng="es">
<VerseOfTheDay />
</YouVersionProvider>
```

## Styling

All component CSS is automatically injected when you wrap your app with `YouVersionProvider` — no extra imports or build steps needed. Under the hood, it uses React 19's [`<style precedence>`](https://react.dev/reference/react-dom/components/style) to hoist styles into `<head>` with built-in deduplication and SSR/Suspense support.
Expand Down
36 changes: 36 additions & 0 deletions packages/ui/src/components/YouVersionProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,24 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import React, { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { YouVersionPlatformConfiguration } from '@youversion/platform-core';
import { YouVersionContext } from '@youversion/platform-react-hooks';
import { YouVersionProvider } from '@/components/YouVersionProvider';
import i18n from '@/i18n';
import en from '@/i18n/locales/en.json';
import es from '@/i18n/locales/es.json';

function AdditionalHeadersProbe(): React.ReactElement {
const headers = useContext(YouVersionContext)?.additionalHeaders;
return <div data-testid="headers">{headers ? JSON.stringify(headers) : 'none'}</div>;
}

function VerseOfTheDayHeading() {
const { t } = useTranslation(undefined, { i18n });
return <p>{t('verseOfTheDay')}</p>;
}

describe('UI YouVersionProvider', () => {
it('forwards additionalHeaders to the underlying hooks provider', () => {
const additionalHeaders = { 'X-YVP-Sdk': 'ReactNativeSDK=1.2.3' };
Expand Down Expand Up @@ -81,4 +90,31 @@ describe('UI YouVersionProvider', () => {
errorSpy.mockRestore();
},
);

it('uses lng for bundled copy instead of the browser language', async () => {
vi.stubGlobal('navigator', {
language: 'en-US',
languages: ['en-US', 'en'],
});

const { rerender } = render(
<YouVersionProvider appKey="test-key" lng="es">
<VerseOfTheDayHeading />
</YouVersionProvider>,
);

expect(await screen.findByText(es.verseOfTheDay)).toBeInTheDocument();
expect(screen.queryByText(en.verseOfTheDay)).not.toBeInTheDocument();

rerender(
<YouVersionProvider appKey="test-key" lng="es-MX">
<VerseOfTheDayHeading />
</YouVersionProvider>,
);

expect(await screen.findByText(es.verseOfTheDay)).toBeInTheDocument();

await i18n.changeLanguage('en');
vi.unstubAllGlobals();
});
});
42 changes: 26 additions & 16 deletions packages/ui/src/components/YouVersionProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,49 @@
import React, { type ComponentProps, Suspense, useEffect } from 'react';
import React, { type ComponentProps, Suspense, useEffect, useLayoutEffect } from 'react';
import { YouVersionPlatformConfiguration } from '@youversion/platform-core';
import { YouVersionProvider as BaseYouVersionProvider } from '@youversion/platform-react-hooks';
import { syncBrowserLanguageFromNavigator } from '@/i18n';
import { syncUiLanguage } from '@/i18n';
import { YvStyles } from '@/lib/yv-styles';
import { YvFonts } from '@/lib/yv-fonts';
import { MissingAppKey } from '@/components/missing-app-key';

export type YouVersionProviderProps = ComponentProps<typeof BaseYouVersionProvider> & {
/**
* Optional UI language (BCP-47 tag, e.g. `es` or `es-MX`). When set, bundled
* copy such as the Verse of the Day heading uses this language instead of
* `navigator.languages`. Hosts like the React Native Expo SDK pass their
* provider locale through a WebView this way.
*/
lng?: string;
};

function resolveTheme(theme: 'light' | 'dark' | 'system' = 'light'): 'light' | 'dark' {
if (theme !== 'system') return theme;
if (!globalThis.window) return 'light';
return globalThis.window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}

export function YouVersionProvider(
props: ComponentProps<typeof BaseYouVersionProvider>,
): React.ReactElement {
useEffect(() => {
syncBrowserLanguageFromNavigator();
}, []);
export function YouVersionProvider(props: YouVersionProviderProps): React.ReactElement {
const { lng, ...baseProps } = props;

useLayoutEffect(() => {
syncUiLanguage(lng);
}, [lng]);

// UI tsup inlines `@youversion/platform-core`, so this singleton is a different
// copy from the one hooks syncs. BibleReader reads appName / signInPromptMessage
// from *this* copy — keep it in sync with the provider props.
useEffect(() => {
YouVersionPlatformConfiguration.appName = props.appName;
YouVersionPlatformConfiguration.signInPromptMessage = props.signInPromptMessage;
}, [props.appName, props.signInPromptMessage]);
YouVersionPlatformConfiguration.appName = baseProps.appName;
YouVersionPlatformConfiguration.signInPromptMessage = baseProps.signInPromptMessage;
}, [baseProps.appName, baseProps.signInPromptMessage]);

// Guard against a missing/empty app key here (rather than letting the base
// provider throw) so consumers of the UI package see a styled message instead
// of a blank page. The visible panel is intentionally generic; the actionable
// fix (set the env var, restart the dev server) goes to console.error for the
// developer. Hooks-only consumers still get a thrown error from the base
// provider.
const missingAppKey = !props.appKey?.trim();
const missingAppKey = !baseProps.appKey?.trim();

// Log from an effect (not the render body) so the guidance is emitted once per
// state change instead of on every re-render and twice under Strict Mode.
Expand All @@ -50,13 +60,13 @@ export function YouVersionProvider(
return (
<>
<YvStyles />
<MissingAppKey theme={resolveTheme(props.theme)} />
<MissingAppKey theme={resolveTheme(baseProps.theme)} />
</>
);
}

return (
<BaseYouVersionProvider {...props}>
<BaseYouVersionProvider {...baseProps}>
<YvStyles />
{/* Only in this branch — the missing-app-key guard above has no key, and
without a key the gated Fonts API request would 401.
Expand All @@ -66,9 +76,9 @@ export function YouVersionProvider(
font link so it can't bubble to the consumer's nearest boundary above
the provider and hold their tree during the Fonts API fetch. */}
<Suspense fallback={null}>
<YvFonts appKey={props.appKey} apiHost={props.apiHost} />
<YvFonts appKey={baseProps.appKey} apiHost={baseProps.apiHost} />
</Suspense>
{props.children}
{baseProps.children}
</BaseYouVersionProvider>
);
}
34 changes: 34 additions & 0 deletions packages/ui/src/i18n/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,4 +234,38 @@ describe('i18n instance', () => {
expect(i18n.language).toBe('en');
expect(i18n.t('verseOfTheDay')).toBe(en.verseOfTheDay);
});

it('uses an explicit host language instead of the browser preference', async () => {
vi.stubGlobal('navigator', {
language: 'en-US',
languages: ['en-US', 'en'],
});
vi.resetModules();

const { default: i18n, syncUiLanguage } = await import('./index');
if (!i18n.isInitialized) {
await new Promise<void>((resolve) => {
const handler = () => {
i18n.off('initialized', handler);
resolve();
};
i18n.on('initialized', handler);
});
}

expect(i18n.language).toBe('en');
expect(i18n.t('verseOfTheDay')).toBe(en.verseOfTheDay);

syncUiLanguage('es');
await vi.waitFor(() => {
expect(i18n.language).toBe('es');
});
expect(i18n.t('verseOfTheDay')).toBe(resources.es.translation.verseOfTheDay);

syncUiLanguage('es-MX');
await vi.waitFor(() => {
expect(i18n.language).toBe('es');
});
expect(i18n.t('verseOfTheDay')).toBe(resources.es.translation.verseOfTheDay);
});
});
17 changes: 12 additions & 5 deletions packages/ui/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,19 @@ const fallbackLng = 'en';
const i18n: I18nInstance = i18next.createInstance();

/**
* Applies the user's browser language when running in a browser.
* Call from YouVersionProvider on mount — do not rely on module-load detection,
* which runs in Node during bundling/dep optimization and locks to fallbackLng.
* Applies bundled UI copy for `lng` when the host provides one (BCP-47, e.g.
* `es` or `es-MX`). When omitted, follows `navigator.languages`.
*
* Call from YouVersionProvider — do not rely on module-load detection, which
* runs in Node during bundling/dep optimization and locks to fallbackLng.
*/
export function syncBrowserLanguageFromNavigator(): void {
const detected = resolveBrowserLanguage(getBrowserLanguages(), supportedLngs, fallbackLng);
export function syncUiLanguage(lng?: string): void {
const trimmed = lng?.trim();
const detected = resolveBrowserLanguage(
trimmed ? [trimmed] : getBrowserLanguages(),
supportedLngs,
fallbackLng,
);
if (i18n.language !== detected) {
void i18n.changeLanguage(detected);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@ export {
type UseYVAuthReturn,
} from '@youversion/platform-react-hooks';

export { YouVersionProvider } from './components/YouVersionProvider';
export { YouVersionProvider, type YouVersionProviderProps } from './components/YouVersionProvider';
Loading