diff --git a/packages/ui/src/components/bible-chapter-picker.tsx b/packages/ui/src/components/bible-chapter-picker.tsx index 68a07d21..f011092b 100644 --- a/packages/ui/src/components/bible-chapter-picker.tsx +++ b/packages/ui/src/components/bible-chapter-picker.tsx @@ -122,6 +122,15 @@ function Root({ }; }, [expandedBook]); + const handleChapterButtonClick = (bookId: string, passageId: string) => { + const chapterId = passageId.split('.').pop() || ''; + if (chapterId && bookId) { + setBook(bookId); + setChapter(chapterId); + setSearchQuery(''); + } + }; + return ( {bookItem.chapters && bookItem.chapters.length > 0 ? (
+ {bookItem.intro?.id && bookItem.intro?.passage_id ? ( + + + + ) : null} {bookItem.chapters.map((chapterRef) => { const chapterId = chapterRef.passage_id.split('.').pop() || ''; return ( @@ -173,17 +199,11 @@ function Root({ variant="secondary" size="icon" className="yv:aspect-square yv:w-full yv:h-full yv:flex yv:items-center yv:justify-center yv:rounded-[4px]" - onClick={() => { - setBook(bookItem.id); - setChapter(chapterId); - setSearchQuery(''); - }} + onClick={() => + handleChapterButtonClick(bookItem.id, chapterRef.passage_id) + } > - {!chapterId.toLowerCase().includes('intro') ? ( - chapterId - ) : ( - - )} + {chapterId} ); @@ -229,7 +249,10 @@ export type TriggerProps = Omit, 'ch | React.ReactNode | ((props: { book: string; + /** Raw chapter ID as passed to the Root component (e.g. "GEN.1", "GEN.INTRO"). */ chapter: string; + /** Display label for the current chapter (e.g. "1", "Intro"). */ + chapterLabel: string; currentBook: BibleBook | undefined; loading: boolean; }) => React.ReactNode); @@ -243,13 +266,18 @@ function Trigger({ asChild = true, children, ...props }: TriggerProps) { const theme = background || providerTheme; const currentBook = books?.data?.find((bookItem) => bookItem.id === book); + let chapterLabel: string = + currentBook?.chapters?.find((ch) => ch.id === chapter)?.title || chapter; + if (!!currentBook?.intro && chapter === currentBook.intro.id) { + chapterLabel = currentBook.intro.title; + } const buttonText = loading ? 'Loading...' - : `${currentBook?.title || 'Select a chapter'}${chapter ? ` ${chapter}` : ''}`; + : `${currentBook?.title || 'Select a chapter'}${chapterLabel ? ` ${chapterLabel}` : ''}`; const content = typeof children === 'function' - ? children({ book, chapter, currentBook, loading }) + ? children({ book, chapter, chapterLabel, currentBook, loading }) : children || ; return ( diff --git a/packages/ui/src/components/bible-reader.stories.tsx b/packages/ui/src/components/bible-reader.stories.tsx index 4a581694..36bde8f9 100644 --- a/packages/ui/src/components/bible-reader.stories.tsx +++ b/packages/ui/src/components/bible-reader.stories.tsx @@ -638,3 +638,82 @@ export const WithoutAuth: Story = { await expect(settingsButton).toBeInTheDocument(); }, }; + +/** + * Tests that a rich intro chapter (Joshua) renders correctly with real-world content + * including structured sections (At a Glance, Purpose, Major Themes), italic spans, + * bold-italic spans, and special formatting classes (imt1, imt2, is, ili, ip, etc.). + * The h1 header should be hidden and all intro content should render. + */ +export const JoshuaIntroChapter: Story = { + tags: ['integration'], + args: { + defaultVersionId: 1849, + book: 'JOS', + chapter: 'INTRO', + }, + render: (args) => ( +
+ + + + +
+ ), + play: async ({ canvasElement }) => { + // Wait for the intro content to fully load (not just the renderer element) + await waitFor( + async () => { + const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + await expect(verseContainer).toBeInTheDocument(); + await expect(verseContainer?.textContent).toContain('Joshua'); + }, + { timeout: 5000 }, + ); + + const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]')!; + + // The h1 header should not be present for intro chapters + const h1 = canvasElement.querySelector('h1'); + await expect(h1).not.toBeInTheDocument(); + + // The unavailable message should not appear + const hasUnavailableText = verseContainer.textContent?.includes('not available'); + await expect(hasUnavailableText).not.toBeTruthy(); + + // Verify key intro content rendered — title and structured sections + await expect(verseContainer.textContent).toContain('Introduction'); + await expect(verseContainer.textContent).toContain('At a Glance'); + await expect(verseContainer.textContent).toContain('Traditionally Joshua'); + await expect(verseContainer.textContent).toContain('Purpose'); + await expect(verseContainer.textContent).toContain('Major Themes'); + + // Verify structured list items rendered (ili class content) + await expect(verseContainer.textContent).toContain('Entering the Land'); + await expect(verseContainer.textContent).toContain('Conquering the Land'); + await expect(verseContainer.textContent).toContain('Dividing the Land'); + + // Verify paragraph content rendered (ip class) + await expect(verseContainer.textContent).toContain( + 'wilderness wanderers to courageous conquerors', + ); + + // Verify special formatting rendered (bdit = bold-italic, nd = divine name) + await expect(verseContainer.textContent).toContain('The Land of Promise'); + await expect(verseContainer.textContent).toContain('Covenant and Obedience'); + await expect(verseContainer.textContent).toContain('The Typology of Jesus'); + await expect(verseContainer.textContent).toContain('Yahweh'); + + // Toolbar trigger should show "Intro" (title-case label), NOT "INTRO" (raw chapter ID) + await waitFor( + async () => { + const chapterButton = screen.getByRole('button', { + name: /change bible book and chapter/i, + }); + await expect(chapterButton.textContent).toContain('Intro'); + await expect(chapterButton.textContent).not.toContain('INTRO'); + }, + { timeout: 5000 }, + ); + }, +}; diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index 9b3fa285..51a538ae 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -194,32 +194,51 @@ function Content() { }, [books?.data, book]); const usfmReference = `${book}.${chapter}`; + const chapterIsNumerical = /^\d+$/.test(chapter || ''); + + // Check if the current chapter is available in this version + const chapterUnavailable = useMemo(() => { + if (!bookData || !chapter) return false; + const inChapters = bookData.chapters?.some((ch) => ch.passage_id.split('.').pop() === chapter); + const isIntro = bookData.intro?.id === chapter; + return !inChapters && !isIntro; + }, [bookData, chapter]); return (
-

- - {bookData?.title || 'Loading...'} - - - {chapter || '-'} - -

- - + {chapterIsNumerical ? ( +

+ + {bookData?.title || 'Loading...'} + + + {chapter || '-'} + +

+ ) : null} + + {chapterUnavailable ? ( + // This copy was taken from bible.com (e.g. https://www.bible.com/bible/4253/ACT.INTRO1.AFV) +

+ This chapter is not available in this version. Please choose a different chapter or + version. +

+ ) : ( + + )} {version?.copyright && (