Skip to content
7 changes: 7 additions & 0 deletions .changeset/puny-pigs-sniff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@youversion/platform-react-hooks': patch
'@youversion/platform-core': patch
'@youversion/platform-react-ui': patch
---

Refactors footnotes implementation to use React portals, improves HTML sanitization, and fixes footnote popover behavior.
115 changes: 115 additions & 0 deletions packages/ui/src/components/bible-reader.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,118 @@ export const RealAPI: Story = {
</div>
),
};

export const FootnotesPersistAfterFontSizeChange: Story = {
tags: ['integration'],
args: {
versionId: 111,
book: 'JHN',
chapter: '1',
background: 'light',
},
render: (args) => (
<div className="yv:h-screen yv:bg-background">
<BibleReader.Root {...args}>
<BibleReader.Content />
<BibleReader.Toolbar />
</BibleReader.Root>
</div>
),
play: async ({ canvasElement }) => {
await waitFor(
async () => {
const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]');
await expect(verseContainer).toBeInTheDocument();
},
{ timeout: 5000 },
);

const getFootnoteButtons = () => canvasElement.querySelectorAll('[data-verse-footnote] button');

await waitFor(
async () => {
const footnoteButtons = getFootnoteButtons();
await expect(footnoteButtons.length).toBe(9);
},
{ timeout: 5000 },
);

const initialFootnoteCount = getFootnoteButtons().length;

const settingsButton = screen.getByRole('button', { name: /settings/i });
await userEvent.click(settingsButton);

await waitFor(async () => {
await expect(await screen.findByText('Reader Settings')).toBeInTheDocument();
});

const increaseFontButton = screen.getByTestId('increase-font-size');
await userEvent.click(increaseFontButton);

await waitFor(async () => {
const footnoteButtons = getFootnoteButtons();
await expect(footnoteButtons.length).toBe(initialFootnoteCount);
});

const decreaseFontButton = screen.getByTestId('decrease-font-size');
await userEvent.click(decreaseFontButton);
await userEvent.click(decreaseFontButton);

await waitFor(async () => {
const footnoteButtons = getFootnoteButtons();
await expect(footnoteButtons.length).toBe(initialFootnoteCount);
});
},
};

export const ThemeOverridesProvider: Story = {
tags: ['integration'],
args: {
versionId: 111,
book: 'JHN',
chapter: '1',
background: 'light',
},
globals: {
theme: 'dark',
},
render: (args) => (
<div className="yv:h-screen yv:bg-background">
<BibleReader.Root {...args}>
<BibleReader.Content />
<BibleReader.Toolbar />
</BibleReader.Root>
</div>
),
play: async ({ canvasElement }) => {
await waitFor(
async () => {
const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]');
await expect(verseContainer).toBeInTheDocument();
},
{ timeout: 5000 },
);

const readerTheme = canvasElement.querySelector('[data-yv-theme="light"]');
await expect(readerTheme).toBeInTheDocument();

await waitFor(
async () => {
const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button');
await expect(footnoteButton).toBeInTheDocument();
},
{ timeout: 5000 },
);

const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button');
await expect(footnoteButton?.closest('[data-yv-theme="light"]')).toBeInTheDocument();

await userEvent.click(footnoteButton!);

await waitFor(async () => {
const popover = document.querySelector('[data-slot="popover-content"]');
await expect(popover).toBeInTheDocument();
await expect(popover?.closest('[data-yv-theme="light"]')).toBeInTheDocument();
});
},
};
2 changes: 2 additions & 0 deletions packages/ui/src/components/bible-reader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ function Root({

function Content() {
const {
background,
book,
chapter,
versionId,
Expand Down Expand Up @@ -191,6 +192,7 @@ function Content() {
fontSize={currentFontSize}
lineHeight={lineHeight}
showVerseNumbers={showVerseNumbers}
theme={background}
/>

{version?.copyright && (
Expand Down
5 changes: 4 additions & 1 deletion packages/ui/src/components/bible-widget-view.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,10 @@ export const WithVersionPicker: Story = {
);
});

await expect(screen.getByText(/luke 1:39-45 amp/i)).toBeVisible();
await waitFor(async () => {
const heading = screen.getByRole('heading', { level: 2, name: /luke 1:39-45/i });
await expect(heading).toHaveTextContent(/amp/i);
});
},
};

Expand Down
83 changes: 83 additions & 0 deletions packages/ui/src/components/verse.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,86 @@ export const FootnoteInteraction: Story = {
});
},
};

export const FootnotePopoverThemeLight: Story = {
args: {
reference: 'JHN.1',
versionId: 111,
renderNotes: true,
showVerseNumbers: true,
theme: 'light',
},
tags: ['integration'],
play: async ({ canvasElement }) => {
await waitFor(
async () => {
const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]');
await expect(verseContainer).toBeInTheDocument();
},
{ timeout: 5000 },
);

await waitFor(
async () => {
const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button');
await expect(footnoteButton).toBeInTheDocument();
},
{ timeout: 5000 },
);

const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button');
await expect(footnoteButton?.closest('[data-yv-theme="light"]')).toBeInTheDocument();

await userEvent.click(footnoteButton!);

await waitFor(async () => {
const popover = document.querySelector('[data-slot="popover-content"]');
await expect(popover).toBeInTheDocument();
await expect(popover?.closest('[data-yv-theme="light"]')).toBeInTheDocument();
});
},
};

export const FootnotePopoverThemeDark: Story = {
args: {
reference: 'JHN.1',
versionId: 111,
renderNotes: true,
showVerseNumbers: true,
theme: 'dark',
},
tags: ['integration'],
render: (args) => (
<div className="yv:dark">
<BibleTextView {...args} />
</div>
),
play: async ({ canvasElement }) => {
await waitFor(
async () => {
const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]');
await expect(verseContainer).toBeInTheDocument();
},
{ timeout: 5000 },
);

await waitFor(
async () => {
const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button');
await expect(footnoteButton).toBeInTheDocument();
},
{ timeout: 5000 },
);

const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button');
await expect(footnoteButton?.closest('[data-yv-theme="dark"]')).toBeInTheDocument();

await userEvent.click(footnoteButton!);

await waitFor(async () => {
const popover = document.querySelector('[data-slot="popover-content"]');
await expect(popover).toBeInTheDocument();
await expect(popover?.closest('[data-yv-theme="dark"]')).toBeInTheDocument();
});
},
};
84 changes: 84 additions & 0 deletions packages/ui/src/components/verse.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,90 @@ describe('Verse.Html - XSS Protection', () => {
});
});

describe('Verse.Html - Footnotes', () => {
it('should extract footnotes and create placeholders', async () => {
const htmlWithFootnotes = `
<div class="p">
<span class="yv-v" v="5"></span><span class="yv-vlbl">5</span>The light shines in the darkness, and the
darkness has not overcome<span class="yv-n f"><span class="fr">1:5 </span><span class="ft">Or </span><span class="fqa">understood</span></span>
it.
</div>
`;

const { container } = render(<Verse.Html html={htmlWithFootnotes} renderNotes={true} />);

await waitFor(() => {
const placeholder = container.querySelector('[data-verse-footnote="5"]');
expect(placeholder).not.toBeNull();
});
});

it('should remove original footnote elements', async () => {
const htmlWithFootnotes = `
<div class="p">
<span class="yv-v" v="5"></span><span class="yv-vlbl">5</span>The light shines<span class="yv-n f"><span class="fr">1:5 </span><span class="ft">Or understood</span></span> it.
</div>
`;

const { container } = render(<Verse.Html html={htmlWithFootnotes} renderNotes={true} />);

await waitFor(() => {
const footnoteElements = container.querySelectorAll('.yv-n.f');
expect(footnoteElements.length).toBe(0);
});
});

it('should place footnote at end of correct verse (v42 not v43)', async () => {
const htmlWithFootnotes = `
<div class="p">
<span class="yv-v" v="42"></span><span class="yv-vlbl">42</span>And he brought him to Jesus.
</div>
<div class="p">
Jesus looked at him and said,
<span class="wj">"You are Simon son of John. You will be called Cephas"</span>
(which, when translated, is Peter<span class="yv-n f"><span class="fr">1:42 </span><span class="fq">Cephas </span><span class="ft">(Aramaic) and </span><span class="fq">Peter </span><span class="ft">(Greek) both mean </span><span class="fqa">rock.</span></span>).
</div>
<div class="s1 yv-h">Jesus Calls Philip and Nathanael</div>
<div class="p">
<span class="yv-v" v="43"></span><span class="yv-vlbl">43</span>The next day Jesus decided to leave for Galilee.
</div>
`;

const { container } = render(<Verse.Html html={htmlWithFootnotes} renderNotes={true} />);

await waitFor(() => {
const placeholder42 = container.querySelector('[data-verse-footnote="42"]');
expect(placeholder42).not.toBeNull();

const verse43Marker = container.querySelector('.yv-v[v="43"]');
expect(verse43Marker).not.toBeNull();

const position42 = placeholder42?.compareDocumentPosition(verse43Marker!);
expect(position42! & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
});

it('should handle multiple footnotes in a single verse', async () => {
const htmlWithMultipleNotes = `
<div class="p">
<span class="yv-v" v="51"></span><span class="yv-vlbl">51</span>He then added,
<span class="wj">"Very truly I tell you,</span><span class="yv-n f"><span class="fr">1:51 </span><span class="ft">The Greek is plural.</span></span>
<span class="wj">you</span><span class="yv-n f"><span class="fr">1:51 </span><span class="ft">The Greek is plural.</span></span>
<span class="wj">will see heaven open."</span>
</div>
`;

const { container } = render(<Verse.Html html={htmlWithMultipleNotes} renderNotes={true} />);

await waitFor(() => {
const placeholder = container.querySelector('[data-verse-footnote="51"]');
expect(placeholder).not.toBeNull();
const footnoteElements = container.querySelectorAll('.yv-n.f');
expect(footnoteElements.length).toBe(0);
});
});
Comment on lines +286 to +304
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This test doesn't seem to be asserting that there are multiple footnotes in the popover element.

});

describe('Verse.Text', () => {
it('should render verse with number and text (default size)', () => {
const { container } = render(<Verse.Text number={1} text="In the beginning" />);
Expand Down
Loading