Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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
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.
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@testing-library/user-event": "^14.6.1",
"@youversion/platform-core": "workspace:*",
"@youversion/platform-react-hooks": "workspace:*",
"class-variance-authority": "0.7.1",
Expand Down
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 @@
</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();

Check failure on line 339 in packages/ui/src/components/bible-reader.stories.tsx

View workflow job for this annotation

GitHub Actions / Integration Tests

src/components/bible-reader.stories.tsx > Theme Overrides Provider

Error: Click to debug the error directly in Storybook: ***/?path=/story/components-biblereader--theme-overrides-provider&addonPanel=storybook/interactions/panel expect(received).toBeInTheDocument() received value must be an HTMLElement or an SVGElement. Received has type: Null Received has value: null Ignored nodes: comments, script, style <html lang="en" > <head> <meta charset="UTF-8" /> <link href="/__vitest__/favicon.svg" rel="icon" type="image/svg+xml" /> <meta content="width=device-width, initial-scale=1.0" name="viewport" /> <title> Vitest Browser Tester </title> <link crossorigin="" href="/__vitest_browser__/utils-uxqdqUz8.js" rel="modulepreload" /> <base target="_parent" /> </head> <body> <span data-radix-focus-guard="" style="outline: none; opacity: 0; position: fixed; pointer-events: none;" tabindex="0" /> <div class="sb-preparing-story sb-wrapper" > <div class="sb-loader" /> </div> <div class="sb-preparing-docs sb-wrapper" > <div class="sb-previewBlock" > <div class="sb-previewBlock_header" > <div class="sb-previewBlock_icon" /> <div class="sb-previewBlock_icon" /> <div class="sb-previewBlock_icon" /> <div class="sb-previewBlock_icon" /> </div> <div class="sb-previewBlock_body" > <div class="sb-loader" /> </div> </div> <table aria-hidden="true" class="sb-argstableBlock" > <thead class="sb-argstableBlock-head" > <tr> <th> <span> Name </span> </th> <th> <span> Description </span> </th> <th> <span> Default </span> </th> <th> <span> Control </span> </th> </tr> </thead> <tbody class="sb-argstableBlock-body" > <tr> <td> <span> propertyName </span> <span title="Required" > * </span> </td> <td> <div> <span> This is a short description </span> </div> <div class="sb-argstableBlock-summary" > <div> <span class="sb-argstableBlock-code" > summary </span> </div> </div> </td> <td> <div> <span class="sb-argstableBlock-code" > defaultValue </span> </div>
});
},
};
4 changes: 2 additions & 2 deletions packages/ui/src/components/bible-reader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,14 +151,14 @@ function Root({

function Content() {
const {
background,
book,
chapter,
versionId,
currentFontSize,
currentFontFamily,
lineHeight,
showVerseNumbers,
background,
} = useBibleReaderContext();
const { books } = useBooks(versionId);
const { version } = useVersion(versionId);
Expand Down Expand Up @@ -186,13 +186,13 @@ function Content() {
</h1>

<BibleTextView
theme={background}
reference={usfmReference}
versionId={versionId}
fontFamily={currentFontFamily}
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 @@ -261,3 +261,86 @@
</section>
),
};

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();

Check failure on line 343 in packages/ui/src/components/verse.stories.tsx

View workflow job for this annotation

GitHub Actions / Integration Tests

src/components/verse.stories.tsx > Footnote Popover Theme Dark

Error: Click to debug the error directly in Storybook: ***/?path=/story/components-bibletextview--footnote-popover-theme-dark&addonPanel=storybook/interactions/panel expect(received).toBeInTheDocument() received value must be an HTMLElement or an SVGElement. Received has type: Null Received has value: null Ignored nodes: comments, script, style <html lang="en" > <head> <meta charset="UTF-8" /> <link href="/__vitest__/favicon.svg" rel="icon" type="image/svg+xml" /> <meta content="width=device-width, initial-scale=1.0" name="viewport" /> <title> Vitest Browser Tester </title> <link crossorigin="" href="/__vitest_browser__/utils-uxqdqUz8.js" rel="modulepreload" /> <base target="_parent" /> </head> <body> <span data-radix-focus-guard="" style="outline: none; opacity: 0; position: fixed; pointer-events: none;" tabindex="0" /> <div class="sb-preparing-story sb-wrapper" > <div class="sb-loader" /> </div> <div class="sb-preparing-docs sb-wrapper" > <div class="sb-previewBlock" > <div class="sb-previewBlock_header" > <div class="sb-previewBlock_icon" /> <div class="sb-previewBlock_icon" /> <div class="sb-previewBlock_icon" /> <div class="sb-previewBlock_icon" /> </div> <div class="sb-previewBlock_body" > <div class="sb-loader" /> </div> </div> <table aria-hidden="true" class="sb-argstableBlock" > <thead class="sb-argstableBlock-head" > <tr> <th> <span> Name </span> </th> <th> <span> Description </span> </th> <th> <span> Default </span> </th> <th> <span> Control </span> </th> </tr> </thead> <tbody class="sb-argstableBlock-body" > <tr> <td> <span> propertyName </span> <span title="Required" > * </span> </td> <td> <div> <span> This is a short description </span> </div> <div class="sb-argstableBlock-summary" > <div> <span class="sb-argstableBlock-code" > summary </span> </div> </div> </td> <td> <div> <span class="sb-argstableBlock-code" > defaultValue </span> </div>
});
},
};
99 changes: 99 additions & 0 deletions packages/ui/src/components/verse.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
import { describe, it, expect } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Verse } from './verse';

describe('Verse.Html - XSS Protection', () => {
Expand Down Expand Up @@ -220,6 +221,104 @@ 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);
});

const footnoteButton = container.querySelector('[data-verse-footnote="51"] button');
expect(footnoteButton).not.toBeNull();

await userEvent.click(footnoteButton!);

await waitFor(() => {
const popover = document.body.querySelector('[role="dialog"]');
expect(popover).not.toBeNull();

const listItems = popover?.querySelectorAll('ul li');
expect(listItems?.length).toBe(2);
});
});
});

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
Loading