Skip to content
Open
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/reader-theme-settings-rn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@youversion/platform-react-ui': minor
---

Expose `BibleThemeSettingsContent` and add optional `onOpenBibleThemeSettings` on `BibleReader.Toolbar` with a **serializable** `BibleThemeSettingsSnapshot` payload for Expo DOM hosts. Export `BIBLE_READER_FONT`, `clampBibleReaderFontSize`, `nextBibleReaderFontSizeUp`, and `nextBibleReaderFontSizeDown` so native can apply edits without closure payloads. Add optional controlled typography on `BibleReader.Root` via `fontSize`/`fontFamily` with `onFontSizeChange`/`onFontFamilyChange` (and `defaultFontSize` / `defaultFontFamily` for defaults).
4 changes: 2 additions & 2 deletions packages/ui/src/components/bible-reader.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,16 +114,16 @@ export const Default: Story = {
await userEvent.click(increaseFontButton);
await expect(localStorage.getItem('youversion-platform:reader:font-size')).toBe('18');
await userEvent.click(increaseFontButton);
await userEvent.click(increaseFontButton);
await expect(localStorage.getItem('youversion-platform:reader:font-size')).toBe('20');
await expect(increaseFontButton).toBeDisabled();
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since we added 2 disabled={fontSize >= MAX_FONT_SIZE} props on the buttons in BibleSettingsContent, we need to reflect that change here.


await userEvent.click(decreaseFontButton);
await userEvent.click(decreaseFontButton);
await expect(localStorage.getItem('youversion-platform:reader:font-size')).toBe('16');
await userEvent.click(decreaseFontButton);
await userEvent.click(decreaseFontButton);
await userEvent.click(decreaseFontButton);
await expect(localStorage.getItem('youversion-platform:reader:font-size')).toBe('12');
await expect(decreaseFontButton).toBeDisabled();

const interButton = screen.getByRole('button', { name: /inter/i });
const sourceSerifButton = screen.getByRole('button', { name: /source serif/i });
Expand Down
266 changes: 266 additions & 0 deletions packages/ui/src/components/bible-reader.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
/**
* @vitest-environment jsdom
*/
// We stub ResizeObserver for jsdom (used by Radix/@floating-ui). The stub methods are intentionally no-ops.
/* eslint-disable @typescript-eslint/no-empty-function */
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import type { BibleBook, BibleVersion, Language } from '@youversion/platform-core';
import {
useBooks,
useFilteredVersions,
useLanguage,
useLanguages,
useTheme,
useVersion,
useVersions,
} from '@youversion/platform-react-hooks';
import {
BibleReader,
clampBibleReaderFontSize,
createBibleThemeSettingsContentHandlers,
nextBibleReaderFontSizeDown,
nextBibleReaderFontSizeUp,
type BibleThemeSettingsSnapshot,
} from './bible-reader';
import { INTER_FONT, SOURCE_SERIF_FONT, type FontFamily } from '@/lib/verse-html-utils';

class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver;

vi.mock('@youversion/platform-react-hooks', async () => {
const actual = await vi.importActual('@youversion/platform-react-hooks');
return {
...actual,
useBooks: vi.fn(),
useFilteredVersions: vi.fn(),
useLanguage: vi.fn(),
useLanguages: vi.fn(),
useTheme: vi.fn(),
useVersion: vi.fn(),
useVersions: vi.fn(),
};
});

const mockBooks: BibleBook[] = [
{
id: 'JHN',
title: 'John',
full_title: 'The Gospel According to John',
canon: 'new_testament',
abbreviation: 'John',
chapters: [
{ id: '1', title: '1', passage_id: 'JHN.1' },
{ id: '2', title: '2', passage_id: 'JHN.2' },
],
},
];

const mockVersion = {
id: 3034,
localized_abbreviation: 'BSB',
abbreviation: 'BSB',
title: 'Berean Standard Bible',
language_tag: 'en',
} as BibleVersion;

function setupDefaultMocks() {
vi.mocked(useTheme).mockReturnValue('light');
vi.mocked(useBooks).mockReturnValue({
books: { data: [...mockBooks], next_page_token: null },
loading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useVersion).mockReturnValue({
version: mockVersion,
loading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useLanguages).mockReturnValue({
languages: { data: [] as Language[], next_page_token: null },
loading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useLanguage).mockReturnValue({
language: { id: 'en', language: 'English', display_names: { en: 'English' } } as Language,
loading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useVersions).mockReturnValue({
versions: { data: [], next_page_token: null },
loading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useFilteredVersions).mockReturnValue([]);
}

describe('BibleReader font helpers', () => {
it('clamps font size to reader bounds', () => {
expect(clampBibleReaderFontSize(8)).toBe(12);
expect(clampBibleReaderFontSize(30)).toBe(20);
expect(clampBibleReaderFontSize(16)).toBe(16);
});

it('steps up and down with clamping at bounds', () => {
expect(nextBibleReaderFontSizeUp(16)).toBe(18);
expect(nextBibleReaderFontSizeUp(20)).toBe(20);
expect(nextBibleReaderFontSizeDown(18)).toBe(16);
expect(nextBibleReaderFontSizeDown(12)).toBe(12);
});
});

describe('createBibleThemeSettingsContentHandlers', () => {
it('updates font size and family via host-owned setters', () => {
let fontSize = 16;
let fontFamily: FontFamily = SOURCE_SERIF_FONT;
const setFontSize = vi.fn((n: number) => {
fontSize = n;
});
const setFontFamily = vi.fn((f: FontFamily) => {
fontFamily = f;
});

const handlers = createBibleThemeSettingsContentHandlers({
getFontSize: () => fontSize,
Comment thread
Dustin-Kelley marked this conversation as resolved.
getFontFamily: () => fontFamily,
setFontSize,
setFontFamily,
});

handlers.onFontIncreased();
expect(setFontSize).toHaveBeenCalledWith(18);

handlers.onFontDecreased();
expect(setFontSize).toHaveBeenLastCalledWith(16);

handlers.onFontSelected(INTER_FONT);
expect(setFontFamily).toHaveBeenCalledWith(INTER_FONT);
});
});

describe('BibleReader theme settings', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
setupDefaultMocks();
});

it('opens the default Reader Settings popover and updates font settings', async () => {
const user = userEvent.setup();

render(
<BibleReader.Root defaultVersionId={3034} defaultBook="JHN" defaultChapter="1">
<BibleReader.Toolbar />
</BibleReader.Root>,
);

await user.click(screen.getByRole('button', { name: 'Settings' }));

expect(await screen.findByText('Reader Settings')).toBeInTheDocument();

await user.click(screen.getByTestId('increase-font-size'));
await waitFor(() => {
expect(localStorage.getItem('youversion-platform:reader:font-size')).toBe('18');
});

await user.click(screen.getByRole('button', { name: /inter/i }));
await waitFor(() => {
expect(localStorage.getItem('youversion-platform:reader:font-family')).toBe(INTER_FONT);
});
});

it('calls onOpenBibleThemeSettings with a serializable snapshot and skips popover content', async () => {
const user = userEvent.setup();
const onOpenBibleThemeSettings = vi.fn();

render(
<BibleReader.Root
defaultVersionId={3034}
defaultBook="JHN"
defaultChapter="1"
fontSize={18}
fontFamily={INTER_FONT}
>
<BibleReader.Toolbar onOpenBibleThemeSettings={onOpenBibleThemeSettings} />
</BibleReader.Root>,
);

await user.click(screen.getByRole('button', { name: 'Settings' }));

expect(onOpenBibleThemeSettings).toHaveBeenCalledTimes(1);
expect(screen.queryByText('Reader Settings')).not.toBeInTheDocument();

const snapshot = onOpenBibleThemeSettings.mock.calls[0]![0] as BibleThemeSettingsSnapshot;
expect(snapshot).toEqual({
fontSize: 18,
fontFamily: INTER_FONT,
minFontSize: 12,
maxFontSize: 20,
});
});

it('applies font updates via controlled props using snapshot and exported font math', async () => {
const user = userEvent.setup();
const onOpen = vi.fn();

function ControlledHost() {
const [fontSize, setFontSize] = useState(16);
const [fontFamily, setFontFamily] = useState<FontFamily>(SOURCE_SERIF_FONT);

return (
<>
<button
type="button"
onClick={() => {
const snap = onOpen.mock.calls[0]?.[0] as BibleThemeSettingsSnapshot | undefined;
if (snap) {
setFontSize(nextBibleReaderFontSizeDown(snap.fontSize));
setFontFamily(INTER_FONT);
}
}}
>
simulate-native-apply
</button>
<BibleReader.Root
defaultVersionId={3034}
defaultBook="JHN"
defaultChapter="1"
fontSize={fontSize}
fontFamily={fontFamily}
onFontSizeChange={setFontSize}
onFontFamilyChange={setFontFamily}
>
<BibleReader.Toolbar onOpenBibleThemeSettings={onOpen} />
</BibleReader.Root>
</>
);
}

render(<ControlledHost />);

await user.click(screen.getByRole('button', { name: 'Settings' }));
expect(onOpen).toHaveBeenCalledTimes(1);

await user.click(screen.getByRole('button', { name: 'simulate-native-apply' }));
await waitFor(() => {
expect(localStorage.getItem('youversion-platform:reader:font-size')).toBeNull();
expect(localStorage.getItem('youversion-platform:reader:font-family')).toBeNull();
});

await user.click(screen.getByRole('button', { name: 'Settings' }));
const nextSnap = onOpen.mock.calls[1]![0] as BibleThemeSettingsSnapshot;
expect(nextSnap.fontSize).toBe(14);
expect(nextSnap.fontFamily).toBe(INTER_FONT);
});
});
Loading
Loading