diff --git a/.changeset/reader-theme-settings-rn.md b/.changeset/reader-theme-settings-rn.md new file mode 100644 index 00000000..985c9758 --- /dev/null +++ b/.changeset/reader-theme-settings-rn.md @@ -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). diff --git a/packages/ui/src/components/bible-reader.stories.tsx b/packages/ui/src/components/bible-reader.stories.tsx index de054da5..b8589e0c 100644 --- a/packages/ui/src/components/bible-reader.stories.tsx +++ b/packages/ui/src/components/bible-reader.stories.tsx @@ -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(); 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 }); diff --git a/packages/ui/src/components/bible-reader.test.tsx b/packages/ui/src/components/bible-reader.test.tsx new file mode 100644 index 00000000..f983ce18 --- /dev/null +++ b/packages/ui/src/components/bible-reader.test.tsx @@ -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, + 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( + + + , + ); + + 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( + + + , + ); + + 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(SOURCE_SERIF_FONT); + + return ( + <> + + + + + + ); + } + + render(); + + 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); + }); +}); diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index 00db3011..d4af5025 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -16,7 +16,8 @@ import { useEffect, useLayoutEffect, useMemo, - useState, + useRef, + type ReactElement, } from 'react'; import { cn } from '@/lib/utils'; import { DEFAULT_LICENSE_FREE_BIBLE_VERSION, getAdjacentChapter } from '@youversion/platform-core'; @@ -72,8 +73,12 @@ export type RootProps = { versionId?: number; defaultVersionId?: number; onVersionChange?: (versionId: number) => void; - fontFamily?: FontFamily; fontSize?: number; + defaultFontSize?: number; + onFontSizeChange?: (fontSize: number) => void; + fontFamily?: FontFamily; + defaultFontFamily?: FontFamily; + onFontFamilyChange?: (fontFamily: FontFamily) => void; lineHeight?: number; showVerseNumbers?: boolean; background?: 'light' | 'dark'; @@ -81,9 +86,80 @@ export type RootProps = { children?: ReactNode; }; -const MIN_FONT_SIZE = 12; -const MAX_FONT_SIZE = 20; -const DEFAULT_FONT_SIZE = 16; +export const BIBLE_READER_FONT = { + MIN: 12, + MAX: 20, + DEFAULT: 16, + STEP: 2, +} as const; + +const MIN_FONT_SIZE = BIBLE_READER_FONT.MIN; +const MAX_FONT_SIZE = BIBLE_READER_FONT.MAX; +const DEFAULT_FONT_SIZE = BIBLE_READER_FONT.DEFAULT; +const FONT_SIZE_STEP = BIBLE_READER_FONT.STEP; + +export type BibleThemeSettingsValues = { + fontSize: number; + fontFamily: FontFamily; +}; + +export type BibleThemeSettingsSnapshot = BibleThemeSettingsValues & { + minFontSize: number; + maxFontSize: number; +}; + +export type BibleThemeSettingsContentProps = { + theme: 'light' | 'dark'; + fontSize: number; + fontFamily: FontFamily; + onFontSelected: (fontFamily: FontFamily) => void; + onFontIncreased: () => void; + onFontDecreased: () => void; +}; + +export function clampBibleReaderFontSize(fontSize: number): number { + return Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, fontSize)); +} + +function normalizeReaderFontSizeForInitialization(size: number): number { + if (size > MAX_FONT_SIZE || size < MIN_FONT_SIZE) { + return DEFAULT_FONT_SIZE; + } + return size; +} + +export function nextBibleReaderFontSizeUp(current: number): number { + return clampBibleReaderFontSize(current + FONT_SIZE_STEP); +} + +export function nextBibleReaderFontSizeDown(current: number): number { + return clampBibleReaderFontSize(current - FONT_SIZE_STEP); +} + +/** + * Builds the three handler props for {@link BibleThemeSettingsContent} from host-owned font state. + * Use this on the same side as `setFontSize` / `setFontFamily` (e.g. React Native before passing + * **top-level** props into a `BibleThemeSettingsContent` Expo DOM wrapper). Expo allows functions as + * top-level DOM props; do not nest these inside a {@link BibleThemeSettingsSnapshot}. + */ +export function createBibleThemeSettingsContentHandlers(options: { + getFontSize: () => number; + getFontFamily: () => FontFamily; + setFontSize: (size: number) => void; + setFontFamily: (fontFamily: FontFamily) => void; +}): Pick { + return { + onFontIncreased: () => { + options.setFontSize(nextBibleReaderFontSizeUp(options.getFontSize())); + }, + onFontDecreased: () => { + options.setFontSize(nextBibleReaderFontSizeDown(options.getFontSize())); + }, + onFontSelected: (fontFamily) => { + options.setFontFamily(fontFamily); + }, + }; +} function Root({ book: controlledBook, @@ -95,8 +171,12 @@ function Root({ versionId: controlledVersionId, defaultVersionId = DEFAULT_LICENSE_FREE_BIBLE_VERSION, onVersionChange, - fontFamily = SOURCE_SERIF_FONT, - fontSize = DEFAULT_FONT_SIZE, + fontSize: fontSizeProp, + defaultFontSize = DEFAULT_FONT_SIZE, + onFontSizeChange, + fontFamily: fontFamilyProp, + defaultFontFamily = SOURCE_SERIF_FONT, + onFontFamilyChange, lineHeight, showVerseNumbers = true, background, @@ -121,36 +201,67 @@ function Root({ onChange: onVersionChange, }); - const validatedFontSize = - fontSize > MAX_FONT_SIZE || fontSize < MIN_FONT_SIZE ? DEFAULT_FONT_SIZE : fontSize; + const validatedDefaultFontSize = + defaultFontSize > MAX_FONT_SIZE || defaultFontSize < MIN_FONT_SIZE + ? DEFAULT_FONT_SIZE + : defaultFontSize; + + const isFontSizeControlled = onFontSizeChange !== undefined; + const isFontFamilyControlled = onFontFamilyChange !== undefined; + + const defaultPropFontSize = normalizeReaderFontSizeForInitialization( + fontSizeProp ?? validatedDefaultFontSize, + ); + + const [currentFontSize, setCurrentFontSize] = useControllableState({ + prop: isFontSizeControlled ? fontSizeProp : undefined, + defaultProp: defaultPropFontSize, + onChange: onFontSizeChange, + }); + + const defaultPropFontFamily = fontFamilyProp ?? defaultFontFamily; + + const [currentFontFamily, setCurrentFontFamily] = useControllableState({ + prop: isFontFamilyControlled ? fontFamilyProp : undefined, + defaultProp: defaultPropFontFamily, + onChange: onFontFamilyChange, + }); - const [currentFontSize, setCurrentFontSize] = useState(validatedFontSize); - const [currentFontFamily, setCurrentFontFamily] = useState(fontFamily); + const didHydrateThemeSettingsRef = useRef(false); - // Load saved preferences from localStorage before paint (avoids flash of default values) useLayoutEffect(() => { - const savedFontSize = localStorage.getItem('youversion-platform:reader:font-size'); - if (savedFontSize) { - const parsed = parseInt(savedFontSize); - if (parsed >= MIN_FONT_SIZE && parsed <= MAX_FONT_SIZE) { - setCurrentFontSize(parsed); + if (didHydrateThemeSettingsRef.current) return; + didHydrateThemeSettingsRef.current = true; + + if (!isFontSizeControlled) { + const savedFontSize = localStorage.getItem('youversion-platform:reader:font-size'); + if (savedFontSize) { + const parsed = parseInt(savedFontSize); + if (parsed >= MIN_FONT_SIZE && parsed <= MAX_FONT_SIZE) { + setCurrentFontSize(parsed); + } } } - const savedFontFamily = localStorage.getItem('youversion-platform:reader:font-family'); - if (savedFontFamily) { - setCurrentFontFamily(savedFontFamily); + if (!isFontFamilyControlled) { + const savedFontFamily = localStorage.getItem('youversion-platform:reader:font-family'); + if (savedFontFamily) { + setCurrentFontFamily(savedFontFamily); + } } - }, []); + }, [isFontFamilyControlled, isFontSizeControlled, setCurrentFontFamily, setCurrentFontSize]); - // Save preferences to localStorage when they change useEffect(() => { - localStorage.setItem('youversion-platform:reader:font-size', currentFontSize.toString()); - }, [currentFontSize]); + if (!isFontSizeControlled) { + localStorage.setItem('youversion-platform:reader:font-size', currentFontSize.toString()); + } + }, [currentFontSize, isFontSizeControlled]); useEffect(() => { - localStorage.setItem('youversion-platform:reader:font-family', currentFontFamily); - }, [currentFontFamily]); + if (!isFontFamilyControlled) { + localStorage.setItem('youversion-platform:reader:font-family', currentFontFamily); + } + }, [currentFontFamily, isFontFamilyControlled]); const providerTheme = useTheme(); const theme = background || providerTheme; @@ -332,7 +443,101 @@ function UserMenu() { ); } -function Toolbar({ border = 'top' }: { border?: 'top' | 'bottom' }) { +export function BibleThemeSettingsContent({ + theme, + fontSize, + fontFamily, + onFontSelected, + onFontIncreased, + onFontDecreased, +}: BibleThemeSettingsContentProps): ReactElement { + return ( +
+
+ + +
+ +
+ + +
+
+ ); +} + +export type BibleReaderToolbarProps = { + border?: 'top' | 'bottom'; + onOpenBibleThemeSettings?: (snapshot: BibleThemeSettingsSnapshot) => void; +}; + +function Toolbar({ border = 'top', onOpenBibleThemeSettings }: BibleReaderToolbarProps) { const { book, chapter, @@ -344,10 +549,64 @@ function Toolbar({ border = 'top' }: { border?: 'top' | 'bottom' }) { booksLoading, currentFontFamily, setCurrentFontFamily, + currentFontSize, setCurrentFontSize, background, } = useBibleReaderContext(); const yvContext = useContext(YouVersionContext); + const themesSettingsValuesRef = useRef({ + fontSize: currentFontSize, + fontFamily: currentFontFamily, + }); + + themesSettingsValuesRef.current = { + fontSize: currentFontSize, + fontFamily: currentFontFamily, + }; + + const applyThemeSettings = ( + themeSettings: BibleThemeSettingsValues, + ): BibleThemeSettingsValues => { + const nextThemeSettings = { + fontSize: clampBibleReaderFontSize(themeSettings.fontSize), + fontFamily: themeSettings.fontFamily, + }; + + themesSettingsValuesRef.current = nextThemeSettings; + setCurrentFontSize(nextThemeSettings.fontSize); + setCurrentFontFamily(nextThemeSettings.fontFamily); + + return nextThemeSettings; + }; + + const handleFontIncreased = (): BibleThemeSettingsValues => { + const settings = themesSettingsValuesRef.current; + return applyThemeSettings({ + ...settings, + fontSize: nextBibleReaderFontSizeUp(settings.fontSize), + }); + }; + + const handleFontDecreased = (): BibleThemeSettingsValues => { + const settings = themesSettingsValuesRef.current; + return applyThemeSettings({ + ...settings, + fontSize: nextBibleReaderFontSizeDown(settings.fontSize), + }); + }; + + const handleFontSelected = (fontFamily: FontFamily): BibleThemeSettingsValues => { + return applyThemeSettings({ + ...themesSettingsValuesRef.current, + fontFamily, + }); + }; + + const buildBibleThemeSettingsSnapshot = (): BibleThemeSettingsSnapshot => ({ + ...themesSettingsValuesRef.current, + minFontSize: MIN_FONT_SIZE, + maxFontSize: MAX_FONT_SIZE, + }); const prevResult = getAdjacentChapter(booksData, book, chapter, 'previous'); const nextResult = getAdjacentChapter(booksData, book, chapter, 'next'); @@ -382,10 +641,7 @@ function Toolbar({ border = 'top' }: { border?: 'top' | 'bottom' }) { > {({ chapterLabel, currentBook, loading }) => ( -
+
- - - -
-
- - -
- -
- - -
-
-
- + {onOpenBibleThemeSettings ? ( + + ) : ( + + + + + + + + + + )}
); diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index e5f96097..e5ecb850 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -6,7 +6,20 @@ export { type BibleChapterPickerContentProps, type BibleChapterPickerPressData, } from './bible-chapter-picker'; -export { BibleReader, type BibleReaderRootProps } from './bible-reader'; +export { + BIBLE_READER_FONT, + BibleReader, + BibleThemeSettingsContent, + clampBibleReaderFontSize, + createBibleThemeSettingsContentHandlers, + nextBibleReaderFontSizeDown, + nextBibleReaderFontSizeUp, + type BibleReaderRootProps, + type BibleReaderToolbarProps, + type BibleThemeSettingsContentProps, + type BibleThemeSettingsSnapshot, + type BibleThemeSettingsValues, +} from './bible-reader'; export { BibleVersionPicker, BibleLanguagePickerContent,