diff --git a/.changeset/reader-shadows-wait.md b/.changeset/reader-shadows-wait.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/reader-shadows-wait.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.size-limit.js b/.size-limit.js index d1e2f6cf..51f7958b 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -49,7 +49,7 @@ export default [ name: 'ui / Separator only', path: 'packages/ui/dist/index.js', import: '{ Separator }', - limit: '21.6 KB', + limit: '21.7 KB', ignore: ['react', 'react-dom', 'react/jsx-runtime', '@tanstack/react-query'], }, { diff --git a/docs/shadow-dom-consumer-compatibility.md b/docs/shadow-dom-consumer-compatibility.md index 7f7e2919..d294412f 100644 --- a/docs/shadow-dom-consumer-compatibility.md +++ b/docs/shadow-dom-consumer-compatibility.md @@ -8,11 +8,17 @@ the SDK's Shadow DOM boundary. YPE-5356 incorporates it into the current stable-package and integration-branch boundary inventory and coordinated release status. -The executable evidence lives in -`consumer-compatibility.shadow-isolation.stories.tsx`. The existing -`bible-version-picker.shadow-isolation.stories.tsx` suite supplies additional -evidence for shadow-aware queries and relationships that stay within one tree -scope. +The executable evidence is grouped by rollout surface: + +- `consumer-compatibility.shadow-isolation.stories.tsx` covers shared consumer + boundary behavior. +- `bible-version-picker.shadow-isolation.stories.tsx` covers shadow-aware picker + queries and relationships that stay within one tree scope. +- `scripture-presentation-shadow-isolation.test.tsx` and + `scripture-presentation.shadow-isolation.stories.tsx` cover `BibleTextView`, + `VerseOfTheDay`, and `BibleCard`. +- `bible-reader-shadow-isolation.test.tsx` and `bible-reader.stories.tsx` cover + the `BibleReader.Root` boundary and consumer composition contract. ## Representative modules @@ -26,6 +32,9 @@ scope. - `BibleTextView`, `VerseOfTheDay`, and `BibleCard` exercise automatic scripture-presentation boundaries and reuse the owning root for composed scripture and picker content. +- `BibleReader.Root` exercises a compound application surface whose arbitrary + React children move into the reader root while reader-owned descendants + reuse that boundary. These modules validate the shared boundary and specific public interfaces they exercise. They do not establish compatibility for every SDK component. @@ -48,6 +57,7 @@ exercise. They do not establish compatibility for every SDK component. | Concurrent peer popovers inside the same or separate component roots | Unsupported as simultaneous peers | Opening a peer dismisses the current popover through Radix outside interaction. YPE-5356 accepts this single-active-peer behavior; supporting simultaneous peers requires a demonstrated product journey and separate design. | | Shadow-local picker relationships | Supported in current browser evidence | The chapter and version picker stories verify that each trigger and controlled panel remain in one root and resolve their `aria-controls` relationship. This does not make cross-scope ID references supported. | | Consumer-supplied picker triggers | Supported within the explicit styling contract | The supplied element remains the interactive trigger. Inline style, ordinary attributes, and SDK-embedded utility classes are preserved. Document/global class rules and document-level token overrides do not cross the root. The SDK does not promise CSS Parts, arbitrary stylesheet injection, or styling of picker internals. | +| Arbitrary `BibleReader.Root` children | Supported inside the reader root; breaking DOM/CSS placement change | Children render in the reader's open shadow root. Their React context and callbacks remain intact, but document-root selectors, global CSS, and document queries no longer reach them. A child that is itself an automatically isolated public component may intentionally create a nested root; reader-owned SDK composition suppresses accidental nesting. YPE-5952 owns the coordinated release documentation. | ## Consumer risks diff --git a/docs/shadow-dom-isolation-plan.md b/docs/shadow-dom-isolation-plan.md index 9910188d..d6024d54 100644 --- a/docs/shadow-dom-isolation-plan.md +++ b/docs/shadow-dom-isolation-plan.md @@ -138,14 +138,13 @@ state confirmed the selected Untitled Serif value, closed dialog, and restored trigger focus. This focused browser evidence does not establish assistive-technology support. -The current light-DOM `BibleReader` suppresses the standalone boundary around -its owned settings. A simulated future reader boundary proves that composition -produces exactly one outer host, no nested settings host, and a settings body in -the outer root. YPE-5952 must document the empty-host client-only contract, -open-root automation, native event retargeting, loss of global-CSS access, and -the supported explicit theme, direction, and callback inputs as part of the -coordinated major release. YPE-5994 has an empty changeset and must not publish -an independent partial rollout. +`BibleReader.Root` suppresses the standalone boundary around its owned settings, +so the settings body remains in the reader root without nesting. YPE-5952 must +document the empty-host client-only contract, open-root automation, native +event retargeting, loss of global-CSS access, and the supported explicit theme, +direction, and callback inputs as part of the coordinated major release. +YPE-5994 has an empty changeset and must not publish an independent partial +rollout. ## YPE-5950 scripture presentation evidence @@ -153,8 +152,8 @@ Standalone `BibleTextView`, `VerseOfTheDay`, and `BibleCard` now each own one client-only open shadow root and reuse the exact empty server host during hydration. `VerseOfTheDay` and `BibleCard` suppress the automatic boundary on their composed `BibleTextView`; `BibleCard` also suppresses it on its composed -version picker. The current light-DOM `BibleReader` suppresses the boundary on -its owned scripture renderer until the reader root lands in YPE-5951. +version picker. `BibleReader` suppresses the standalone `BibleTextView` +boundary so its owned scripture renderer reuses the reader root. Focused unit coverage verifies the exact host matrix, hydration without nested or duplicate roots, open-root queries, native event retargeting, the React verse @@ -182,6 +181,44 @@ portal path and the application-root retargeted path. The shared-host correction and click, keyboard, and focus regression coverage are assigned to YPE-6040 and must land before YPE-5952 releases the coordinated major version. +## YPE-5951 Bible reader evidence + +`BibleReader.Root` now owns one client-only open shadow root and reuses the +exact empty server host during hydration. Reader-owned content, toolbar, +search, scripture, chapter and version pickers, theme settings, avatar, verse +actions, and authentication dialogs remain in that root; the chapter and +version picker `Root` components explicitly reuse it. A consumer may still +intentionally render another public isolated component as an arbitrary reader +child, which creates the documented nested-root topology. + +Focused unit evidence verifies hydration without recoverable errors, empty +light DOM, no accidental reader-owned roots, direct `BibleReaderSearch` +placement, arbitrary child relocation, and intentional public nesting. The +existing detailed reader behavior suites continue at their reusable +implementation seams. The existing search and verse-selection browser +journeys traverse the reader root, and focused hostile-CSS/constrained-layout +coverage reviews theme, interface and Scripture direction, typography, +scrolling, and geometry in Chromium, Firefox, and Playwright WebKit. + +The concrete `examples/vite-react` reader page was also reviewed in its +`h-[calc(100vh-3.5rem)]` shell beneath the example navbar: the reader surface +continues to fill that shell, its toolbar remains above the independently +scrolling scripture pane, and the constrained browser evidence retains usable +toolbar and scripture geometry. Interactive review found no obvious +user-visible performance regression while opening reader-owned settings, +search, verse actions, and auth overlays; each reuses the one reader root and +local overlay rather than adding duplicate automatic hosts. This is a +qualitative product-layout check, not benchmark evidence. + +The ADR's client-only first-paint gap is accepted for the reviewed reader +layout: without JavaScript the reader remains absent, and nearby layout can +move after the passive-effect mount. This evidence is qualitative and does not +claim zero layout shift, a benchmark, actual-Safari coverage, or +assistive-technology coverage. YPE-5952 must document that arbitrary +`BibleReader.Root` children move into the open root, so document-root queries +and global CSS no longer reach them. YPE-6040's ancestor React-event dispatch +correction remains out of scope. + ## Safari spacing evidence Actual Safari 26.6.2 exposed a visual gap the initial focused assertions missed: diff --git a/packages/ui/src/components/bible-reader-controlled.test.tsx b/packages/ui/src/components/bible-reader-controlled.test.tsx index 032caf3c..71585f18 100644 --- a/packages/ui/src/components/bible-reader-controlled.test.tsx +++ b/packages/ui/src/components/bible-reader-controlled.test.tsx @@ -201,9 +201,11 @@ function renderReader(props: Partial = {}, overrides?: Hoo it('keeps reader-owned scripture in the reader tree without a standalone shadow boundary', async () => { const { container } = render( - - - + + + + + , ); diff --git a/packages/ui/src/components/bible-reader-navigation.test.tsx b/packages/ui/src/components/bible-reader-navigation.test.tsx index e6a90711..194c0c7e 100644 --- a/packages/ui/src/components/bible-reader-navigation.test.tsx +++ b/packages/ui/src/components/bible-reader-navigation.test.tsx @@ -5,6 +5,7 @@ import { BibleReader, useBibleReaderContext } from './bible-reader'; import { BibleReaderNavigation } from './bible-reader-navigation'; import { HookOverrideProvider } from '@/test/hook-overrides'; import type { HookOverrides } from '@youversion/platform-react-hooks'; +import { ReuseShadowBoundary } from '@/lib/shadow-isolation'; function CurrentDestination() { const { book, chapter, versionId, verseFocus } = useBibleReaderContext(); @@ -26,9 +27,15 @@ it('consumes the newest pre-mount request once and supports mounted cross-versio const jsx = ( - - - + + + + + ); @@ -102,9 +109,11 @@ it('renders passage-only and full-chapter destinations through the reader fetch' render( - - - + + + + + , ); @@ -145,19 +154,21 @@ it('waits for controlled destinations and never revives an activated passage or const reader = (versionId: number, book: string, chapter: string) => ( - - - - + + + + + + ); diff --git a/packages/ui/src/components/bible-reader-search.stories.tsx b/packages/ui/src/components/bible-reader-search.stories.tsx index c775d63d..8228a6d4 100644 --- a/packages/ui/src/components/bible-reader-search.stories.tsx +++ b/packages/ui/src/components/bible-reader-search.stories.tsx @@ -1,11 +1,19 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, screen, userEvent, waitFor, within } from 'storybook/test'; +import { expect, fireEvent, userEvent, waitFor, within } from 'storybook/test'; import { BibleReader } from './bible-reader'; -import { useState } from 'react'; +import { useContext, useState, type ReactNode } from 'react'; +import { + YouVersionContext, + type BibleSearchPhase, + type BibleSearchResult, + type UseBibleSearchResult, +} from '@youversion/platform-react-hooks'; import { BibleReaderNavigation } from './bible-reader-navigation'; import { Button } from './ui/button'; import { delay, http, HttpResponse } from 'msw'; import { globalHandlers } from '@/test/mocks/handlers'; +import mockPassages from '@/test/mock-data/passages.json'; +import { waitForShadowContent, waitForShadowRoot } from '@/test/storybook-dom'; const meta = { title: 'Components/BibleReaderSearch', @@ -19,6 +27,41 @@ export default meta; type Story = StoryObj; +async function getReaderStory(canvasElement: HTMLElement) { + const root = await waitForShadowRoot(canvasElement); + const content = await waitForShadowContent(root); + return { root, reader: within(content) }; +} + +async function getOpenSearch(canvasElement: HTMLElement) { + const { root, reader } = await getReaderStory(canvasElement); + const overlay = await waitFor(() => { + const element = [...root.querySelectorAll('[data-yv-shadow-local-overlay]')].find( + (candidate) => candidate.querySelector('[role="dialog"][data-state="open"]'), + ); + if (!element) throw new Error('reader search overlay not mounted'); + return element; + }); + return { root, reader, search: within(overlay) }; +} + +async function typeInShadowInput(input: HTMLElement, text: string) { + const submit = text.endsWith('{Enter}'); + const value = submit ? text.slice(0, -'{Enter}'.length) : text; + // Storybook user-event resolves document.activeElement to the shadow host, + // so its keyboard helpers cannot type into this shadow-local controlled input. + await fireEvent.change(input, { target: { value } }); + if (submit) { + // Wait for React to commit the controlled value before Enter reads it. + const inputRoot = input.getRootNode(); + if (!(inputRoot instanceof ShadowRoot)) throw new Error('search input is not shadow-local'); + await waitFor(() => + expect(inputRoot.querySelector('button[aria-label="Clear search"]')).toBeVisible(), + ); + await fireEvent.keyDown(input, { key: 'Enter' }); + } +} + export const OpenTrending: Story = { tags: ['integration'], args: { @@ -35,23 +78,25 @@ export const OpenTrending: Story = { ), play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); await waitFor( async () => { - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); }, { timeout: 5000 }, ); const searchButton = await waitFor( - () => screen.getByRole('button', { name: 'Search the Bible' }), + () => reader.getByRole('button', { name: 'Search the Bible' }), { timeout: 5000 }, ); await userEvent.click(searchButton); + const { search } = await getOpenSearch(canvasElement); await waitFor(async () => { await expect( - within(screen.getByRole('region', { name: 'Trending Searches' })).getByRole('button', { + within(search.getByRole('region', { name: 'Trending Searches' })).getByRole('button', { name: 'love', }), ).toBeInTheDocument(); @@ -85,25 +130,25 @@ export const NavigateAndFocus: Story = { tags: ['integration'], render: () => , play: async ({ canvasElement }) => { + const { root } = await getReaderStory(canvasElement); let focused: HTMLElement | null = null; await waitFor( async () => { - focused = canvasElement.querySelector('.yv-v-focused[v="51"]'); + focused = root.querySelector('.yv-v-focused[v="51"]'); await expect(focused).not.toBeNull(); }, { timeout: 5000 }, ); - const scroller = canvasElement.querySelector('main'); + const scroller = root.querySelector('main'); await expect(scroller).not.toBeNull(); - await expect(scroller!.scrollTop).toBeGreaterThan(0); const bounds = focused!.getBoundingClientRect(); const viewport = scroller!.getBoundingClientRect(); await expect(bounds.top).toBeGreaterThanOrEqual(viewport.top); await expect(bounds.bottom).toBeLessThanOrEqual(viewport.bottom); - await expect(focused).toHaveFocus(); + await expect(root.activeElement).toBe(focused); // A pointer left over the verse can apply its ordinary hover background. await userEvent.unhover(focused!); - const surrounding = canvasElement.querySelector('.yv-v[v="50"]')!; + const surrounding = root.querySelector('.yv-v[v="50"]')!; await waitFor(async () => { await expect(getComputedStyle(focused!).opacity).toBe('1'); await expect(getComputedStyle(focused!).backgroundColor).toBe('rgba(0, 0, 0, 0)'); @@ -130,61 +175,158 @@ export const SearchFailure: Story = { }, play: async (context) => { await OpenTrending.play?.(context); + const { search } = await getOpenSearch(context.canvasElement); await userEvent.click( - within(screen.getByRole('region', { name: 'Trending Searches' })).getByRole('button', { + within(search.getByRole('region', { name: 'Trending Searches' })).getByRole('button', { name: 'love', }), ); - await expect(await screen.findByRole('alert')).toBeVisible(); - await expect(screen.getByRole('button', { name: 'Try again' })).toBeEnabled(); - await expect(screen.getByRole('button', { name: 'Back to search' })).toBeEnabled(); + await expect(await search.findByRole('alert')).toBeVisible(); + await expect(search.getByRole('button', { name: 'Try again' })).toBeEnabled(); + await expect(search.getByRole('button', { name: 'Back to search' })).toBeEnabled(); }, }; +const searchResult: BibleSearchResult = { + id: 'JHN.1.51', + book: 'JHN', + chapter: '1', + verses: [51], +}; + +function searchJourneyPassage(usfm: string) { + return Object.entries(mockPassages).find(([reference]) => reference === usfm)?.[1] ?? null; +} + +function useSearchStoryFixture(): UseBibleSearchResult { + const [query, setQuery] = useState(''); + const [phase, setPhase] = useState({ + kind: 'trending', + queries: [{ text: 'love' }], + loading: false, + }); + const selectSuggestion = (text: string): void => { + setQuery(text); + setPhase({ kind: 'results', verses: [searchResult], nextPage: 'none' }); + }; + return { + query, + phase, + setQuery, + submit: () => selectSuggestion(query), + selectSuggestion, + loadMore: () => undefined, + retry: () => undefined, + }; +} + +function SearchJourneyFixture({ children }: { children: ReactNode }) { + const context = useContext(YouVersionContext); + if (context === null) throw new Error('Search story requires YouVersionProvider'); + return ( + ({ + passage: searchJourneyPassage(usfm), + loading: false, + error: null, + refetch: () => undefined, + }), + }, + }} + > + {children} + + ); +} + export const SearchAndReturn: Story = { ...OpenTrending, - tags: ['integration'], - parameters: { - msw: { - handlers: [ - http.get('*/v1/search-verses', () => - HttpResponse.json({ - verses: [{ reference: 'JHN.1' }, { reference: 'JHN.1.51' }], - did_you_mean: [], - search_instead_for: null, - next_page_token: null, - }), - ), - ...globalHandlers, - ], - }, - }, + tags: ['integration', 'shadow-dom', 'cross-browser'], + render: (args) => ( +
+ + + + + + + +
+ ), play: async (context) => { - await OpenTrending.play?.(context); - const input = screen.getByRole('textbox', { name: 'Search the Bible' }); - await expect(input).toHaveFocus(); - await userEvent.type(input, 'angels{Enter}'); - const result = await screen.findByRole('button', { name: /John 1:51/i }); - await expect( - screen.queryByRole('button', { name: /^John 1(?:\s|$)/i }), - ).not.toBeInTheDocument(); + const { root, reader } = await getReaderStory(context.canvasElement); + const trigger = await reader.findByRole('button', { name: 'Search the Bible' }); + await userEvent.click(trigger); + const { search } = await getOpenSearch(context.canvasElement); + await expect(root.activeElement).toBe( + await search.findByRole('textbox', { name: 'Search the Bible' }), + ); + const trending = within(search.getByRole('region', { name: 'Trending Searches' })); + await userEvent.click(trending.getByRole('button', { name: 'love' })); + const result = await search.findByRole( + 'button', + { name: /(?:John 1:51|JHN\.1\.51)/i }, + { timeout: 20000 }, + ); await userEvent.click(result); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - await waitFor(() => expect(context.canvasElement.querySelector('.yv-v[v="51"]')).toHaveFocus()); - await userEvent.click(screen.getByRole('button', { name: 'Search the Bible' })); - await expect(screen.getByRole('textbox')).toHaveValue(''); - const recents = within(screen.getByRole('region', { name: 'Recent Searches' })); - await waitFor(() => expect(recents.getByRole('button', { name: 'angels' })).toBeVisible()); - await userEvent.click(screen.getByRole('button', { name: 'Close search' })); - await waitFor(() => - expect(screen.getByRole('button', { name: 'Search the Bible' })).toHaveFocus(), + await waitFor(() => expect(search.queryByRole('dialog')).not.toBeInTheDocument()); + const destinationVerse = await waitFor(async () => { + const element = root.querySelector('.yv-v[v="51"]'); + if (!element) throw new Error('destination verse not rendered'); + await expect(root.activeElement).toBe(element); + return element; + }); + + await userEvent.click(trigger); + const { search: reopenedSearch } = await getOpenSearch(context.canvasElement); + await userEvent.click(reopenedSearch.getByRole('button', { name: 'Close search' })); + await waitFor(() => expect(root.activeElement).toBe(trigger)); + + const outsideControl = context.canvasElement.querySelector( + '[data-testid="outside-reader-control"]', + ); + if (!outsideControl) throw new Error('outside focus control missing'); + outsideControl.focus(); + await userEvent.click(destinationVerse); + const verseActions = await waitFor(() => { + const element = root.querySelector( + '[data-slot="verse-action-popover"][data-state="open"]', + ); + if (!element) throw new Error('verse actions did not open'); + return element; + }); + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(verseActions).not.toBeInTheDocument()); + await expect(context.canvasElement.ownerDocument.activeElement).toBe(outsideControl); + + await userEvent.click(trigger); + const { search: overlaySearch } = await getOpenSearch(context.canvasElement); + await expect(root.activeElement).toBe( + await overlaySearch.findByRole('textbox', { name: 'Search the Bible' }), ); - await userEvent.click(screen.getByRole('button', { name: 'Search the Bible' })); - await expect(screen.getByRole('dialog')).not.toHaveAttribute('aria-modal', 'true'); - await userEvent.click(screen.getByRole('button', { name: 'Settings' })); + await userEvent.click(destinationVerse); await waitFor(() => - expect(screen.queryByRole('textbox', { name: 'Search the Bible' })).not.toBeInTheDocument(), + expect( + overlaySearch.queryByRole('dialog', { name: 'Search the Bible' }), + ).not.toBeInTheDocument(), ); + const reopenedVerseActions = await waitFor(() => { + const element = root.querySelector( + '[data-slot="verse-action-popover"][data-state="open"]', + ); + if (!element) throw new Error('verse actions did not reopen'); + return element; + }); + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(reopenedVerseActions).not.toBeInTheDocument()); + await expect(root.activeElement).toBe(destinationVerse); }, }; @@ -203,13 +345,14 @@ export const EmptyResults: Story = { }, play: async (context) => { await OpenTrending.play?.(context); - await userEvent.type(screen.getByRole('textbox'), 'no matches{Enter}'); + const { search } = await getOpenSearch(context.canvasElement); + await typeInShadowInput(search.getByRole('textbox'), 'no matches{Enter}'); await waitFor(() => - expect(screen.queryByRole('status', { name: 'Loading' })).not.toBeInTheDocument(), + expect(search.queryByRole('status', { name: 'Loading' })).not.toBeInTheDocument(), ); - await expect(screen.getByRole('status')).toBeVisible(); - await expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - await expect(screen.getByRole('textbox')).toHaveValue('no matches'); + await expect(search.getByRole('status')).toBeVisible(); + await expect(search.queryByRole('alert')).not.toBeInTheDocument(); + await expect(search.getByRole('textbox')).toHaveValue('no matches'); }, }; @@ -229,9 +372,10 @@ export const SearchLoading: Story = { }, play: async (context) => { await OpenTrending.play?.(context); - await userEvent.type(screen.getByRole('textbox'), 'patience{Enter}'); - await expect(screen.getByRole('status', { name: 'Loading' })).toBeVisible(); - await expect(screen.getByRole('button', { name: 'Back to search' })).toBeEnabled(); + const { search } = await getOpenSearch(context.canvasElement); + await typeInShadowInput(search.getByRole('textbox'), 'patience{Enter}'); + await expect(search.getByRole('status', { name: 'Loading' })).toBeVisible(); + await expect(search.getByRole('button', { name: 'Back to search' })).toBeEnabled(); }, }; @@ -259,25 +403,26 @@ export const VersePreviewsLoading: Story = { }, play: async (context) => { await OpenTrending.play?.(context); + const { search } = await getOpenSearch(context.canvasElement); await Promise.all( - screen + search .getByRole('dialog') .getAnimations() .map((animation) => animation.finished), ); - await userEvent.type(screen.getByRole('textbox'), 'love{Enter}'); - const searchSpinnerTop = screen + await typeInShadowInput(search.getByRole('textbox'), 'love{Enter}'); + const searchSpinnerTop = search .getByRole('status', { name: 'Loading' }) .parentElement!.getBoundingClientRect().top; await waitFor(() => - expect(screen.getByRole('dialog').querySelector('li[hidden]')).not.toBeNull(), + expect(search.getByRole('dialog').querySelector('li[hidden]')).not.toBeNull(), ); - await expect(screen.getAllByRole('status', { name: 'Loading' })).toHaveLength(1); + await expect(search.getAllByRole('status', { name: 'Loading' })).toHaveLength(1); await expect( - screen.getByRole('status', { name: 'Loading' }).parentElement!.getBoundingClientRect().top, + search.getByRole('status', { name: 'Loading' }).parentElement!.getBoundingClientRect().top, ).toBe(searchSpinnerTop); await expect( - within(screen.getByRole('dialog')).queryByRole('button', { name: /John 3:16/i }), + within(search.getByRole('dialog')).queryByRole('button', { name: /John 3:16/i }), ).not.toBeInTheDocument(); }, }; @@ -309,23 +454,24 @@ export const TestamentFilters: Story = { }, play: async (context) => { await OpenTrending.play?.(context); - await userEvent.type(screen.getByRole('textbox'), 'love{Enter}'); - await waitFor(() => expect(screen.getByRole('button', { name: /John 3:16/i })).toBeVisible()); - await expect(screen.getByRole('button', { name: 'Filters' })).toHaveAttribute( + const { root, search } = await getOpenSearch(context.canvasElement); + await typeInShadowInput(search.getByRole('textbox'), 'love{Enter}'); + await waitFor(() => expect(search.getByRole('button', { name: /John 3:16/i })).toBeVisible()); + await expect(search.getByRole('button', { name: 'Filters' })).toHaveAttribute( 'aria-expanded', 'false', ); - await userEvent.click(screen.getByRole('button', { name: 'Filters' })); - await userEvent.click(screen.getByRole('button', { name: 'Old Testament' })); - await waitFor(() => expect(screen.getByRole('button', { name: /Genesis 1:1/i })).toBeVisible()); - await expect(screen.queryByRole('button', { name: /John 3:16/i })).not.toBeInTheDocument(); - await userEvent.click(screen.getByRole('button', { name: 'New Testament' })); - await waitFor(() => expect(screen.getByRole('button', { name: /John 3:16/i })).toBeVisible()); - await expect(screen.queryByRole('button', { name: /Genesis 1:1/i })).not.toBeInTheDocument(); - await userEvent.click(screen.getByRole('button', { name: 'Back to search' })); - await expect(screen.getByRole('textbox')).toHaveValue(''); - await expect(screen.getByRole('textbox')).toHaveFocus(); - await expect(screen.getByRole('button', { name: 'Close search' })).toBeVisible(); - await expect(screen.queryByRole('button', { name: 'Clear search' })).not.toBeInTheDocument(); + await userEvent.click(search.getByRole('button', { name: 'Filters' })); + await userEvent.click(search.getByRole('button', { name: 'Old Testament' })); + await waitFor(() => expect(search.getByRole('button', { name: /Genesis 1:1/i })).toBeVisible()); + await expect(search.queryByRole('button', { name: /John 3:16/i })).not.toBeInTheDocument(); + await userEvent.click(search.getByRole('button', { name: 'New Testament' })); + await waitFor(() => expect(search.getByRole('button', { name: /John 3:16/i })).toBeVisible()); + await expect(search.queryByRole('button', { name: /Genesis 1:1/i })).not.toBeInTheDocument(); + await userEvent.click(search.getByRole('button', { name: 'Back to search' })); + await expect(search.getByRole('textbox')).toHaveValue(''); + await expect(root.activeElement).toBe(search.getByRole('textbox')); + await expect(search.getByRole('button', { name: 'Close search' })).toBeVisible(); + await expect(search.queryByRole('button', { name: 'Clear search' })).not.toBeInTheDocument(); }, }; diff --git a/packages/ui/src/components/bible-reader-search.test.tsx b/packages/ui/src/components/bible-reader-search.test.tsx index 02a606d3..9701a659 100644 --- a/packages/ui/src/components/bible-reader-search.test.tsx +++ b/packages/ui/src/components/bible-reader-search.test.tsx @@ -4,7 +4,7 @@ import { describe, it, expect, vi } from 'vitest'; import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { useState, type ReactElement } from 'react'; +import { useState, type ComponentProps, type ReactElement } from 'react'; import type { BibleBook, BiblePassage, BibleVersion } from '@youversion/platform-core'; import type { BibleSearchPhase, @@ -15,11 +15,20 @@ import type { import { HookOverrideProvider } from '@/test/hook-overrides'; import { installResizeObserverStub } from '@/test/dom-stubs'; import { RECENT_SEARCHES_KEY } from '@/lib/use-recent-searches'; +import { ReuseShadowBoundary } from '@/lib/shadow-isolation'; import { BibleReader } from './bible-reader'; import { BibleReaderSearch } from './bible-reader-search'; installResizeObserverStub(); +function ReaderRoot(props: ComponentProps) { + return ( + + + + ); +} + const john316: BibleSearchResult = { id: 'JHN.3.16', book: 'JHN', @@ -117,10 +126,10 @@ function baseOverrides(search: UseBibleSearchResult): HookOverrides { function renderSearch(search: UseBibleSearchResult, extra?: ReactElement) { return render( - + {extra} - + , ); } @@ -195,9 +204,9 @@ describe('BibleReaderSearch', () => { const user = userEvent.setup(); render( - + - + , ); const filters = screen.getByRole('button', { name: 'Filters' }); @@ -379,9 +388,9 @@ describe('BibleReaderSearch', () => { }); return ( - + - + ); } @@ -457,9 +466,9 @@ describe('BibleReaderSearch', () => { phase: { kind: 'results', verses, nextPage }, })} > - + - + , ); next('loading'); @@ -525,9 +534,9 @@ describe('BibleReaderSearch', () => { const user = userEvent.setup(); const view = render( - + - + , ); await user.click(screen.getByRole('button', { name: 'Search the Bible' })); @@ -596,9 +605,9 @@ describe('BibleReaderSearch', () => { }); render( - + - + , ); const user = userEvent.setup(); @@ -625,7 +634,7 @@ describe('BibleReaderSearch', () => { }), )} > - { >

{`${book}.${chapter}`}

-
+ ); } @@ -657,14 +666,14 @@ describe('BibleReader.Toolbar search control', () => { - - + , ); await user.click(screen.getByRole('button', { name: 'Search the Bible' })); @@ -681,9 +690,9 @@ describe('BibleReader.Toolbar search control', () => { - + - + , ); diff --git a/packages/ui/src/components/bible-reader-search.tsx b/packages/ui/src/components/bible-reader-search.tsx index 28543877..b5ab7c87 100644 --- a/packages/ui/src/components/bible-reader-search.tsx +++ b/packages/ui/src/components/bible-reader-search.tsx @@ -20,6 +20,7 @@ import { useState, type ReactElement, type RefCallback, + type RefObject, } from 'react'; import { useTranslation } from 'react-i18next'; import { useBibleReaderContext } from './bible-reader'; @@ -69,6 +70,7 @@ export function BibleReaderSearch({ const { background, onSearchPress, versionId, book, chapter } = useBibleReaderContext(); const history = useRecentSearches(); const navigating = useRef(false); + const inputRef = useRef(null); const [open, setOpen] = useControllableState({ prop: openProp, defaultProp: defaultOpen ?? false, @@ -115,9 +117,14 @@ export function BibleReaderSearch({ if (navigating.current) event.preventDefault(); navigating.current = false; }} + onOpenAutoFocus={(event) => { + event.preventDefault(); + inputRef.current?.focus(); + }} > setOpen(false)} onNavigate={() => { navigating.current = true; @@ -134,11 +141,13 @@ function SearchPanel({ onNavigate, recentSearches, rememberSearch, + inputRef, }: { onClose: () => void; onNavigate: () => void; recentSearches: readonly string[]; rememberSearch: (query: string) => void; + inputRef: RefObject; }): ReactElement { const { t } = useTranslation(undefined, { i18n }); const { versionId, navigation, booksData, booksLoading } = useBibleReaderContext(); @@ -158,7 +167,6 @@ function SearchPanel({ ? undefined : booksData.filter((book) => book.canon === testament).map((book) => book.id), }); - const inputRef = useRef(null); const scrollRef = useRef(null); const hasQuery = search.query !== ''; const showingResults = !['trending', 'suggesting'].includes(search.phase.kind); @@ -206,7 +214,6 @@ function SearchPanel({ type="text" enterKeyHint="search" value={search.query} - autoFocus placeholder={t('searchPlaceholder')} aria-label={t('bibleSearchAriaLabel', 'Search the Bible')} onChange={(event) => search.setQuery(event.target.value)} diff --git a/packages/ui/src/components/bible-reader-shadow-isolation.test.tsx b/packages/ui/src/components/bible-reader-shadow-isolation.test.tsx new file mode 100644 index 00000000..669a5b9a --- /dev/null +++ b/packages/ui/src/components/bible-reader-shadow-isolation.test.tsx @@ -0,0 +1,229 @@ +/** + * @vitest-environment jsdom + */ +import { act, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { BibleBook, BibleVersion } from '@youversion/platform-core'; +import type { HookOverrides } from '@youversion/platform-react-hooks'; +import { type ReactElement } from 'react'; +import { hydrateRoot, type Root } from 'react-dom/client'; +import { renderToString } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import { installResizeObserverStub } from '@/test/dom-stubs'; +import { HookOverrideProvider } from '@/test/hook-overrides'; +import { BibleReader } from './bible-reader'; +import { BibleReaderSearch } from './bible-reader-search'; +import { ProfileAvatar } from './profile-avatar'; + +installResizeObserverStub(); + +const books: BibleBook[] = [ + { + id: 'JHN', + title: 'John', + full_title: 'The Gospel According to John', + abbreviation: 'John', + canon: 'new_testament', + chapters: [{ id: '1', title: '1', passage_id: 'JHN.1' }], + }, +]; + +const version: BibleVersion = { + id: 111, + title: 'New International Version', + abbreviation: 'NIV', + localized_title: 'New International Version', + localized_abbreviation: 'NIV', + language_tag: 'en', + books: ['JHN'], + youversion_deep_link: 'https://bible.com/versions/111', +}; + +const overrides: HookOverrides = { + useBooks: () => ({ + books: { data: books, next_page_token: null }, + loading: false, + error: null, + refetch: () => undefined, + }), + usePassage: () => ({ + passage: { + id: 'JHN.1', + reference: 'John 1', + content: '

Reader scripture

', + }, + loading: false, + error: null, + refetch: () => undefined, + }), + useVersion: () => ({ version, loading: false, error: null, refetch: () => undefined }), + useLanguages: () => ({ + languages: { data: [], next_page_token: null }, + loading: false, + error: null, + refetch: () => undefined, + }), + useLanguage: () => ({ + language: { id: 'en', language: 'English', display_names: { en: 'English' } }, + loading: false, + error: null, + refetch: () => undefined, + }), + useVersions: () => ({ + versions: { data: [], next_page_token: null }, + loading: false, + error: null, + refetch: () => undefined, + }), + useFilteredVersions: () => [], + useOrganizations: () => ({ organizations: new Map() }), + useBibleSearch: () => ({ + query: '', + phase: { kind: 'trending', queries: [], loading: false }, + setQuery: () => undefined, + submit: () => undefined, + selectSuggestion: () => undefined, + loadMore: () => undefined, + retry: () => undefined, + }), +}; + +function withOverrides(element: ReactElement): ReactElement { + return {element}; +} + +describe('BibleReader.Root public shadow boundary', () => { + it('owns one empty SSR host and reuses it for all reader-owned content', async () => { + const scrollToDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTo'); + const showPopoverDescriptor = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'showPopover', + ); + const hidePopoverDescriptor = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'hidePopover', + ); + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { + configurable: true, + value: vi.fn(), + }); + Object.defineProperty(HTMLElement.prototype, 'showPopover', { + configurable: true, + value: vi.fn(), + }); + Object.defineProperty(HTMLElement.prototype, 'hidePopover', { + configurable: true, + value: vi.fn(), + }); + const element = withOverrides( + + + + , + ); + const serverMarkup = renderToString(element); + + expect(serverMarkup).toBe('
'); + + const container = document.createElement('div'); + container.innerHTML = serverMarkup; + document.body.append(container); + const serverHost = container.firstElementChild; + const recoverableErrors: unknown[] = []; + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + let root: Root | undefined; + + try { + await act(async () => { + root = hydrateRoot(container, element, { + onRecoverableError: (error) => recoverableErrors.push(error), + }); + }); + + const host = await waitFor(() => { + const candidate = container.querySelector('[data-yv-shadow-host]'); + if (!candidate?.shadowRoot?.querySelector('[data-slot="yv-bible-renderer"]')) { + throw new Error('reader content not mounted inside its open shadow root'); + } + return candidate; + }); + + expect(host).toBe(serverHost); + expect(host.childNodes).toHaveLength(0); + expect(container.querySelectorAll('[data-yv-shadow-host]')).toHaveLength(1); + expect(host.shadowRoot?.querySelectorAll('[data-yv-shadow-host]')).toHaveLength(0); + expect( + host.shadowRoot?.querySelector('button[aria-label="Change Bible book and chapter"]'), + ).toBeInTheDocument(); + expect( + host.shadowRoot?.querySelector('button[aria-label="Change Bible version"]'), + ).toBeInTheDocument(); + const settingsTrigger = host.shadowRoot?.querySelector( + 'button[aria-label="Settings"]', + ); + expect(settingsTrigger).toBeInTheDocument(); + await userEvent.click(settingsTrigger!); + const settings = await waitFor(() => { + const candidate = host.shadowRoot?.querySelector('[role="dialog"]'); + if (!candidate?.textContent?.includes('Reader Settings')) { + throw new Error('reader settings did not open in the reader root'); + } + return candidate; + }); + expect(settings.getRootNode()).toBe(host.shadowRoot); + expect(settings.querySelector('[data-yv-shadow-host]')).toBeNull(); + expect(host.shadowRoot?.querySelectorAll('[data-yv-shadow-host]')).toHaveLength(0); + expect(settings.querySelector('[data-testid="increase-font-size"]')?.getRootNode()).toBe( + host.shadowRoot, + ); + expect(recoverableErrors).toEqual([]); + expect(consoleError).not.toHaveBeenCalled(); + } finally { + if (root) await act(async () => root?.unmount()); + consoleError.mockRestore(); + container.remove(); + if (scrollToDescriptor) { + Object.defineProperty(HTMLElement.prototype, 'scrollTo', scrollToDescriptor); + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'scrollTo'); + } + if (showPopoverDescriptor) { + Object.defineProperty(HTMLElement.prototype, 'showPopover', showPopoverDescriptor); + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'showPopover'); + } + if (hidePopoverDescriptor) { + Object.defineProperty(HTMLElement.prototype, 'hidePopover', hidePopoverDescriptor); + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'hidePopover'); + } + } + }); + + it('relocates consumer children while preserving intentional nested roots and direct search', async () => { + const { container } = render( + withOverrides( + +

Consumer child

+ + +
, + ), + ); + + const host = await waitFor(() => { + const candidate = container.querySelector('[data-yv-shadow-host]'); + if (!candidate?.shadowRoot?.querySelector('[data-testid="consumer-child"]')) { + throw new Error('consumer child not relocated into reader root'); + } + return candidate; + }); + const readerRoot = host.shadowRoot!; + const nestedHost = readerRoot.querySelector('[data-yv-shadow-host]'); + + expect(container.querySelector('[data-testid="consumer-child"]')).toBeNull(); + expect(document.querySelector('[data-testid="consumer-child"]')).toBeNull(); + expect(readerRoot.querySelector('button[aria-label="Search the Bible"]')).toBeInTheDocument(); + expect(nestedHost?.shadowRoot?.querySelector('[data-slot="avatar"]')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/bible-reader.stories.tsx b/packages/ui/src/components/bible-reader.stories.tsx index 3178ab7e..6bca42d9 100644 --- a/packages/ui/src/components/bible-reader.stories.tsx +++ b/packages/ui/src/components/bible-reader.stories.tsx @@ -1,7 +1,12 @@ import { expandPassageId } from '@/lib/highlight-projection'; import { INTER_FONT, SOURCE_SERIF_FONT, UNTITLED_SERIF_FONT } from '@/lib/verse-html-utils'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import type { Highlight } from '@youversion/platform-core'; +import { + YouVersionPlatformConfiguration, + YouVersionUserInfo, + type Highlight, +} from '@youversion/platform-core'; +import { YouVersionAuthContext } from '@youversion/platform-react-hooks'; import { delay, http, HttpResponse } from 'msw'; import { useState } from 'react'; import { expect, fn, screen, spyOn, userEvent, waitFor, within } from 'storybook/test'; @@ -14,6 +19,22 @@ import { type BibleReaderRootProps, } from './bible-reader'; import { VerseActionPopover } from './verse-action-popover'; +import { waitForShadowContent, waitForShadowRoot } from '@/test/storybook-dom'; + +async function getReaderStory(canvasElement: HTMLElement) { + const root = await waitForShadowRoot(canvasElement); + const content = await waitForShadowContent(root); + return { root, reader: within(content) }; +} + +async function getReaderOverlay(root: ShadowRoot) { + const overlay = await waitFor(() => { + const element = root.querySelector('[data-yv-shadow-local-overlay]'); + if (!element) throw new Error('reader overlay not rendered'); + return element; + }); + return within(overlay); +} async function getPickerQueries(container: ParentNode, triggerName: RegExp | string) { return waitFor(() => { @@ -137,32 +158,34 @@ export const Default: Story = { ), play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); await waitFor( async () => { - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); }, { timeout: 5000 }, ); - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); - const themeContainer = canvasElement.querySelector('[data-yv-theme="light"]'); + const themeContainer = root.querySelector('[data-yv-theme="light"]'); await expect(themeContainer).toBeInTheDocument(); - const settingsButton = screen.getByRole('button', { name: /settings/i }); + const settingsButton = reader.getByRole('button', { name: /settings/i }); await userEvent.click(settingsButton); + const settings = await getReaderOverlay(root); await waitFor(async () => { - await expect(await screen.findByText('Reader Settings')).toBeInTheDocument(); + await expect(await settings.findByText('Reader Settings')).toBeInTheDocument(); }); - const fontButtons = screen.getAllByRole('button', { name: /font/i }); + const fontButtons = settings.getAllByRole('button', { name: /font/i }); await expect(fontButtons.length).toBe(4); - const decreaseFontButton = screen.getByTestId('decrease-font-size'); - const increaseFontButton = screen.getByTestId('increase-font-size'); + const decreaseFontButton = settings.getByTestId('decrease-font-size'); + const increaseFontButton = settings.getByTestId('increase-font-size'); await userEvent.click(increaseFontButton); await expect(localStorage.getItem('youversion-platform:reader:font-size')).toBe('18'); @@ -178,8 +201,8 @@ export const Default: Story = { await expect(localStorage.getItem('youversion-platform:reader:font-size')).toBe('12'); await expect(decreaseFontButton).toBeDisabled(); - const interButton = screen.getByRole('button', { name: /inter/i }); - const untitledSerifButton = screen.getByRole('button', { name: /untitled/i }); + const interButton = settings.getByRole('button', { name: /inter/i }); + const untitledSerifButton = settings.getByRole('button', { name: /untitled/i }); await userEvent.click(untitledSerifButton); await expect(localStorage.getItem('youversion-platform:reader:font-family')).toBe( @@ -192,7 +215,7 @@ export const Default: Story = { }; export const VerseSelectionReanchoringDismissalAndFocusRestoration: Story = { - tags: ['integration'], + tags: ['integration', 'shadow-dom', 'cross-browser'], args: { defaultVersionId: 111, lineSpacing: 1.7, @@ -221,12 +244,16 @@ export const VerseSelectionReanchoringDismissalAndFocusRestoration: Story = { ), play: async ({ canvasElement }) => { - const firstVerse = await waitFor(() => { - const element = canvasElement.querySelector('.yv-v[v="1"]'); - if (!element) throw new Error('first verse not rendered'); - return element; - }); - const secondVerse = canvasElement.querySelector('.yv-v[v="2"]'); + const root = await waitForShadowRoot(canvasElement); + const firstVerse = await waitFor( + () => { + const element = root.querySelector('.yv-v[v="1"]'); + if (!element) throw new Error('first verse not rendered'); + return element; + }, + { timeout: 10000 }, + ); + const secondVerse = root.querySelector('.yv-v[v="2"]'); const secondVerseLabel = secondVerse?.querySelector('.yv-vlbl'); const outsideControl = canvasElement.querySelector( '[data-testid="outside-reader-control"]', @@ -237,18 +264,41 @@ export const VerseSelectionReanchoringDismissalAndFocusRestoration: Story = { const ownerDocument = canvasElement.ownerDocument; await waitFor(() => expect(ownerDocument.activeElement).toBe(outsideControl)); await userEvent.click(firstVerse); - let dialog = await screen.findByRole('dialog'); + const overlay = await waitFor(() => { + const element = root.querySelector('[data-yv-shadow-local-overlay]'); + if (!element) throw new Error('reader verse-action overlay not mounted'); + return element; + }); + const reader = within(overlay); + let dialog = await reader.findByRole('dialog'); - await expect(dialog.getRootNode()).toBe(ownerDocument); - await expect(ownerDocument.body).toContainElement(dialog); - await waitFor(() => expect(ownerDocument.activeElement).toBe(dialog)); + await expect(dialog.getRootNode()).toBe(root); + await expect(root.contains(dialog)).toBe(true); + await waitFor(() => expect(root.activeElement).toBe(dialog)); await userEvent.keyboard('{Escape}'); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + await waitFor(() => expect(reader.queryByRole('dialog')).not.toBeInTheDocument()); await expect(ownerDocument.activeElement).toBe(outsideControl); + const externalShadowHost = ownerDocument.createElement('div'); + const externalShadowRoot = externalShadowHost.attachShadow({ mode: 'open' }); + const externalShadowControl = ownerDocument.createElement('button'); + externalShadowControl.textContent = 'External shadow control'; + externalShadowRoot.append(externalShadowControl); + outsideControl.after(externalShadowHost); + externalShadowControl.focus(); + await expect(externalShadowRoot.activeElement).toBe(externalShadowControl); + + await userEvent.click(firstVerse); + dialog = await reader.findByRole('dialog'); + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(dialog).not.toBeInTheDocument(), { timeout: 10000 }); + await expect(externalShadowRoot.activeElement).toBe(externalShadowControl); + externalShadowHost.remove(); + outsideControl.focus(); + await userEvent.click(firstVerse); - dialog = await screen.findByRole('dialog'); + dialog = await reader.findByRole('dialog'); const firstDialogRect = dialog.getBoundingClientRect(); await userEvent.click(secondVerseLabel); @@ -263,7 +313,7 @@ export const VerseSelectionReanchoringDismissalAndFocusRestoration: Story = { }); await userEvent.pointer({ keys: '[MouseLeft>]', target: outsideControl }); - await waitFor(() => expect(dialog).not.toBeInTheDocument()); + await waitFor(() => expect(dialog).not.toBeInTheDocument(), { timeout: 10000 }); await expect(firstVerse).not.toHaveClass('yv-v-selected'); await expect(secondVerse).not.toHaveClass('yv-v-selected'); await userEvent.pointer({ keys: '[/MouseLeft]', target: outsideControl }); @@ -273,7 +323,7 @@ export const VerseSelectionReanchoringDismissalAndFocusRestoration: Story = { // composed path is already empty when Radix asks whether to dismiss. const touchUser = userEvent.setup({ document: ownerDocument }); await touchUser.pointer({ keys: '[TouchA]', target: firstVerse }); - dialog = await screen.findByRole('dialog'); + dialog = await reader.findByRole('dialog'); // Radix disables animations until placement completes. Measure after both // so initial positioning or animation cannot count as verse reanchoring. await waitFor(() => expect(dialog.style.animation).not.toBe('none')); @@ -292,13 +342,192 @@ export const VerseSelectionReanchoringDismissalAndFocusRestoration: Story = { await touchUser.pointer({ keys: '[TouchA>]', target: outsideControl }); await expect(dialog).toHaveAttribute('data-state', 'open'); await touchUser.pointer({ keys: '[/TouchA]', target: outsideControl }); - await waitFor(() => expect(dialog).not.toBeInTheDocument()); + await waitFor(() => expect(dialog).not.toBeInTheDocument(), { timeout: 10000 }); await expect(firstVerse).not.toHaveClass('yv-v-selected'); await expect(secondVerse).not.toHaveClass('yv-v-selected'); await expect(ownerDocument.activeElement).toBe(outsideControl); }, }; +export const HostileCssConstrainedLayout: Story = { + tags: ['integration', 'shadow-dom', 'cross-browser'], + args: { + defaultVersionId: 111, + defaultBook: 'JHN', + defaultChapter: '1', + background: 'dark', + fontFamily: INTER_FONT, + fontSize: 18, + onFontFamilyChange: fn(), + onFontSizeChange: fn(), + onChangeLineSpacing: fn(), + scriptureDirection: 'ltr', + }, + globals: { + interfaceDirection: 'rtl', + locale: 'en', + }, + render: (args) => ( +
+ + +

Consumer child

+ + +
+
+ ), + play: async ({ canvasElement }) => { + const root = await waitForShadowRoot(canvasElement); + const body = root.querySelector( + '[data-yv-shadow-content-wrapper] > [data-yv-sdk]', + ); + const consumerChild = root.querySelector('[data-testid="consumer-reader-child"]'); + const renderer = await waitFor( + () => { + const element = root.querySelector('[data-slot="yv-bible-renderer"]'); + if (!element) throw new Error('reader scripture not mounted'); + return element; + }, + { timeout: 10000 }, + ); + const scroller = root.querySelector('main'); + const toolbar = root.querySelector('section'); + if (!body || !consumerChild || !scroller || !toolbar) { + throw new Error('reader layout evidence not rendered'); + } + + await expect(canvasElement.querySelector('[data-testid="consumer-reader-child"]')).toBeNull(); + await expect(body).toHaveAttribute('data-yv-theme', 'dark'); + await expect(body).toHaveAttribute('dir', 'rtl'); + await expect(renderer).toHaveAttribute('dir', 'ltr'); + await expect(getComputedStyle(renderer).fontFamily).toContain('Inter'); + await expect(getComputedStyle(renderer).fontSize).toBe('18px'); + await expect(getComputedStyle(scroller).overflowY).toBe('auto'); + await expect(getComputedStyle(consumerChild).color).not.toBe('rgb(255, 0, 0)'); + await expect(consumerChild.getBoundingClientRect().width).toBeLessThanOrEqual(390); + await expect(toolbar.getBoundingClientRect().width).toBeLessThanOrEqual(390); + }, +}; + +async function openReaderHighlightAuthDialog(canvasElement: HTMLElement) { + const root = await waitForShadowRoot(canvasElement); + const verse = await waitFor(() => { + const element = root.querySelector('.yv-v[v="1"]'); + if (!element) throw new Error('highlight-auth verse not rendered'); + return element; + }); + await userEvent.click(verse); + const overlay = await waitFor(() => { + const element = root.querySelector('[data-yv-shadow-local-overlay]'); + if (!element) throw new Error('reader auth overlay not mounted'); + return element; + }); + const overlays = within(overlay); + const colors = await overlays.findByRole('group', { name: 'Highlight colors' }); + const apply = Array.from(colors.querySelectorAll('button')).find((button) => + button.getAttribute('aria-label')?.includes('Apply'), + ); + if (!apply) throw new Error('highlight apply control not rendered'); + await userEvent.click(apply); + return { apply, overlays, root }; +} + +export const SignedOutHighlightAuthOwnership: Story = { + tags: ['integration', 'shadow-dom', 'cross-browser'], + args: { + defaultVersionId: 111, + defaultBook: 'JHN', + defaultChapter: '1', + }, + render: (args) => ( +
+ + + +
+ ), + play: async ({ canvasElement }) => { + const { apply, overlays, root } = await openReaderHighlightAuthDialog(canvasElement); + const yes = await overlays.findByRole('button', { name: 'Yes Please' }); + const dialog = yes.closest('[role="dialog"]'); + if (!dialog) throw new Error('sign-in dialog not rendered'); + await expect(dialog.getRootNode()).toBe(root); + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(overlays.queryByRole('button', { name: 'Yes Please' })).toBeNull()); + // The shared local overlay stays mounted because the verse-actions dialog + // remains open underneath the dismissed auth dialog. + await expect(overlays.getByRole('dialog', { name: 'Verse actions' })).toBeInTheDocument(); + await expect(root.activeElement).toBe(apply); + }, +}; + +function SignedInPermissionReader(args: BibleReaderRootProps) { + const [userInfo] = useState( + () => + new YouVersionUserInfo({ + id: 'mock-user-id', + name: 'Test User', + email: 'test@example.com', + }), + ); + + return ( + +
+ + + + +
+
+ ); +} + +export const SignedInHighlightPermissionOwnership: Story = { + tags: ['integration', 'shadow-dom', 'cross-browser'], + args: { + defaultVersionId: 111, + defaultBook: 'JHN', + defaultChapter: '1', + }, + parameters: { + msw: { + handlers: [ + http.get('*/v1/highlights', () => HttpResponse.json({ data: [], next_page_token: null })), + ...globalHandlers, + ], + }, + }, + render: (args) => , + play: async ({ canvasElement }) => { + await setupAuthenticatedUser(); + YouVersionPlatformConfiguration.clearGrantedPermissions(); + const { root: readerRoot, reader } = await getReaderStory(canvasElement); + const avatarTrigger = await reader.findByTestId('user-menu-trigger'); + const avatar = avatarTrigger.querySelector('[data-slot="avatar"]'); + await expect(avatar).toBeInTheDocument(); + await expect(avatar?.getRootNode()).toBe(readerRoot); + await expect(readerRoot.querySelector('[data-yv-shadow-host]')).toBeNull(); + + const { apply, overlays, root } = await openReaderHighlightAuthDialog(canvasElement); + const continueButton = await overlays.findByRole('button', { name: 'Continue' }); + const dialog = continueButton.closest('[role="dialog"]'); + if (!dialog) throw new Error('permission dialog not rendered'); + await expect(dialog.getRootNode()).toBe(root); + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(overlays.queryByRole('button', { name: 'Continue' })).toBeNull()); + // The reader-owned overlay container still serves the open verse-actions dialog. + await expect(overlays.getByRole('dialog', { name: 'Verse actions' })).toBeInTheDocument(); + await expect(root.activeElement).toBe(apply); + }, +}; + const rtlPassage = { id: 'JHN.1', content: @@ -346,13 +575,14 @@ export const RtlScriptureWithLtrChrome: Story = { ), play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); await waitFor(async () => { - const renderer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const renderer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(renderer).toHaveAttribute('dir', 'rtl'); await expect(renderer?.textContent).toContain('في البدء'); }); - const renderer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]')!; + const renderer = root.querySelector('[data-slot="yv-bible-renderer"]')!; const poetry = renderer.querySelector('.q1')!; const table = renderer.querySelector('table'); const rendererStyle = getComputedStyle(renderer); @@ -361,24 +591,25 @@ export const RtlScriptureWithLtrChrome: Story = { await expect(Number.parseFloat(poetryStyle.paddingRight)).toBeGreaterThan(0); await expect(poetryStyle.paddingLeft).toBe('0px'); await expect(table).toBeInTheDocument(); - const heading = canvasElement.querySelector('h1')!; + const heading = root.querySelector('h1')!; await expect(heading).toHaveAttribute('dir', 'rtl'); await waitFor(async () => { await expect(heading.querySelectorAll('bdi[dir="auto"]')).toHaveLength(2); }); - await userEvent.click(screen.getByRole('button', { name: /footnote/i })); + await userEvent.click(reader.getByRole('button', { name: /footnote/i })); + const overlays = await getReaderOverlay(root); await waitFor(async () => { - const dialog = await screen.findByRole('dialog'); + const dialog = await overlays.findByRole('dialog'); await expect(dialog).toHaveAttribute('dir', 'ltr'); await expect(dialog.querySelector('[data-yv-sdk]')).toHaveAttribute('dir', 'rtl'); - await expect(await screen.findByText('حاشية عربية.')).toBeInTheDocument(); + await expect(await overlays.findByText('حاشية عربية.')).toBeInTheDocument(); }); - await userEvent.click(screen.getByRole('button', { name: /close/i })); + await userEvent.click(overlays.getByRole('button', { name: /close/i })); - await userEvent.click(screen.getByRole('button', { name: /settings/i })); + await userEvent.click(reader.getByRole('button', { name: /settings/i })); await waitFor(async () => { - const settings = await screen.findByText('Reader Settings'); + const settings = await overlays.findByText('Reader Settings'); await expect(settings.closest('[role="dialog"]')).toHaveAttribute('dir', 'ltr'); }); }, @@ -413,9 +644,10 @@ export const ArabicInterfaceAndScripture: Story = { ), play: async ({ canvasElement }) => { + const { root } = await getReaderStory(canvasElement); await waitFor(async () => { - await expect(canvasElement.querySelector('[data-yv-sdk][dir="rtl"]')).toBeInTheDocument(); - await expect(canvasElement.querySelector('[data-slot="yv-bible-renderer"]')).toHaveAttribute( + await expect(root.querySelector('[data-yv-sdk][dir="rtl"]')).toBeInTheDocument(); + await expect(root.querySelector('[data-slot="yv-bible-renderer"]')).toHaveAttribute( 'dir', 'rtl', ); @@ -455,9 +687,10 @@ export const ArabicInterfaceWithEnglishScripture: Story = { ), play: async ({ canvasElement }) => { + const { root } = await getReaderStory(canvasElement); const renderer = await waitFor(async () => { - await expect(canvasElement.querySelector('[data-yv-sdk][dir="rtl"]')).toBeInTheDocument(); - const element = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + await expect(root.querySelector('[data-yv-sdk][dir="rtl"]')).toBeInTheDocument(); + const element = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(element).toHaveAttribute('dir', 'ltr'); return element!; }); @@ -497,6 +730,7 @@ export const ForcedRtlChromeGeometry: Story = { ), play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); const { root: chapterRoot, picker: chapterPicker, @@ -505,13 +739,13 @@ export const ForcedRtlChromeGeometry: Story = { const previous = chapterPicker.getByRole('button', { name: 'Previous chapter' }); const next = chapterPicker.getByRole('button', { name: 'Next chapter' }); const renderer = await waitFor(async () => { - const element = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const element = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(element).toBeInTheDocument(); return element; }); await expect(renderer).toHaveAttribute('dir', 'ltr'); - await expect(canvasElement.querySelector('h1')).toHaveAttribute('dir', 'ltr'); + await expect(root.querySelector('h1')).toHaveAttribute('dir', 'ltr'); await expect(previous.getBoundingClientRect().left).toBeGreaterThan( chapter.getBoundingClientRect().left, ); @@ -563,8 +797,8 @@ export const ForcedRtlChromeGeometry: Story = { ).toHaveAttribute('dir', 'auto'); await userEvent.click(versionCanvas.getByRole('button', { name: 'Close' })); - await userEvent.click(screen.getByRole('button', { name: 'Settings' })); - const settingsDialog = await screen.findByRole('dialog'); + await userEvent.click(reader.getByRole('button', { name: 'Settings' })); + const settingsDialog = await (await getReaderOverlay(root)).findByRole('dialog'); const settingsCanvas = within(settingsDialog); const decrease = settingsCanvas.getByTestId('decrease-font-size'); const increase = settingsCanvas.getByTestId('increase-font-size'); @@ -673,9 +907,10 @@ export const CustomStyling: Story = { ), play: async ({ canvasElement }) => { + const { root } = await getReaderStory(canvasElement); await waitFor( async () => { - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); }, { timeout: 5000 }, @@ -703,9 +938,10 @@ export const FontSizeOutOfRange: Story = { ), play: async ({ canvasElement }) => { + const { root } = await getReaderStory(canvasElement); await waitFor( async () => { - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); }, { timeout: 5000 }, @@ -750,15 +986,16 @@ export const FootnotesPersistAfterFontSizeChange: Story = { ), play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); await waitFor( async () => { - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); }, { timeout: 5000 }, ); - const getFootnoteButtons = () => canvasElement.querySelectorAll('[data-verse-footnote] button'); + const getFootnoteButtons = () => root.querySelectorAll('[data-verse-footnote] button'); await waitFor( async () => { @@ -770,14 +1007,15 @@ export const FootnotesPersistAfterFontSizeChange: Story = { const initialFootnoteCount = getFootnoteButtons().length; - const settingsButton = screen.getByRole('button', { name: /settings/i }); + const settingsButton = reader.getByRole('button', { name: /settings/i }); await userEvent.click(settingsButton); + const settings = await getReaderOverlay(root); await waitFor(async () => { - await expect(await screen.findByText('Reader Settings')).toBeInTheDocument(); + await expect(await settings.findByText('Reader Settings')).toBeInTheDocument(); }); - const increaseFontButton = screen.getByTestId('increase-font-size'); + const increaseFontButton = settings.getByTestId('increase-font-size'); await userEvent.click(increaseFontButton); await waitFor(async () => { @@ -785,7 +1023,7 @@ export const FootnotesPersistAfterFontSizeChange: Story = { await expect(footnoteButtons.length).toBe(initialFootnoteCount); }); - const decreaseFontButton = screen.getByTestId('decrease-font-size'); + const decreaseFontButton = settings.getByTestId('decrease-font-size'); await userEvent.click(decreaseFontButton); await userEvent.click(decreaseFontButton); @@ -816,32 +1054,34 @@ export const ThemeOverridesProvider: Story = { ), play: async ({ canvasElement }) => { + const { root } = await getReaderStory(canvasElement); await waitFor( async () => { - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); }, { timeout: 5000 }, ); - const readerTheme = canvasElement.querySelector('[data-yv-theme="light"]'); + const readerTheme = root.querySelector('[data-yv-theme="light"]'); await expect(readerTheme).toBeInTheDocument(); await waitFor( async () => { - const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button'); + const footnoteButton = root.querySelector('[data-verse-footnote] button'); await expect(footnoteButton).toBeInTheDocument(); }, { timeout: 5000 }, ); - const footnoteButton = canvasElement.querySelector('[data-verse-footnote] button'); + const footnoteButton = root.querySelector('[data-verse-footnote] button'); await expect(footnoteButton?.closest('[data-yv-theme="light"]')).toBeInTheDocument(); await userEvent.click(footnoteButton!); + const overlays = await getReaderOverlay(root); await waitFor(async () => { - const popover = document.querySelector('[data-slot="popover-content"]'); + const popover = overlays.queryByRole('dialog'); await expect(popover).toBeInTheDocument(); await expect(popover?.closest('[data-yv-theme="light"]')).toBeInTheDocument(); }); @@ -863,26 +1103,28 @@ export const SignInFlow: Story = { ), - play: async () => { + play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); await waitFor( async () => { - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); await expect(userMenuTrigger).toBeInTheDocument(); }, { timeout: 5000 }, ); - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); await expect(userMenuTrigger.querySelector('img')).not.toBeInTheDocument(); await userEvent.click(userMenuTrigger); + const overlays = await getReaderOverlay(root); await waitFor(async () => { - const signInButton = await screen.findByRole('button', { name: /sign in/i }); + const signInButton = await overlays.findByRole('button', { name: /sign in/i }); await expect(signInButton).toBeInTheDocument(); }); - const signInButton = screen.getByRole('button', { name: /sign in/i }); + const signInButton = overlays.getByRole('button', { name: /sign in/i }); await userEvent.click(signInButton); await expect(signInMock).toHaveBeenCalled(); @@ -910,16 +1152,17 @@ export const SignOutFlow: Story = { ), - play: async () => { + play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); await waitFor( async () => { - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); await expect(userMenuTrigger).toBeInTheDocument(); }, { timeout: 5000 }, ); - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); await waitFor(async () => { const avatar = userMenuTrigger.querySelector('[data-slot="avatar"]'); @@ -928,16 +1171,17 @@ export const SignOutFlow: Story = { await userEvent.click(userMenuTrigger); + const overlays = await getReaderOverlay(root); await waitFor(async () => { - const signOutButton = await screen.findByRole('button', { name: /sign out/i }); + const signOutButton = await overlays.findByRole('button', { name: /sign out/i }); await expect(signOutButton).toBeInTheDocument(); }); - const signOutButton = screen.getByRole('button', { name: /sign out/i }); + const signOutButton = overlays.getByRole('button', { name: /sign out/i }); await userEvent.click(signOutButton); await waitFor(async () => { - const userMenuTriggerAfterSignOut = screen.getByTestId('user-menu-trigger'); + const userMenuTriggerAfterSignOut = reader.getByTestId('user-menu-trigger'); await expect(userMenuTriggerAfterSignOut.querySelector('img')).not.toBeInTheDocument(); }); }, @@ -964,16 +1208,17 @@ export const AuthenticatedWithAvatar: Story = { ), - play: async () => { + play: async ({ canvasElement }) => { + const { reader } = await getReaderStory(canvasElement); await waitFor( async () => { - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); await expect(userMenuTrigger).toBeInTheDocument(); }, { timeout: 5000 }, ); - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); // Radix only renders the once it loads successfully. await waitFor(async () => { @@ -1008,18 +1253,17 @@ export const LoadsSavedPreferencesFromLocalStorage: Story = { ), play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); await waitFor( async () => { - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); }, { timeout: 5000 }, ); // Verify the saved settings were applied via CSS variables - const verseContainer = canvasElement.querySelector( - '[data-slot="yv-bible-renderer"]', - )!; + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]')!; await expect(verseContainer.style.getPropertyValue('--yv-reader-font-size')).toBe('18px'); // The legacy Source Serif value seeded above is migrated forward on hydrate. await expect(verseContainer.style.getPropertyValue('--yv-reader-font-family')).toBe( @@ -1030,17 +1274,18 @@ export const LoadsSavedPreferencesFromLocalStorage: Story = { ); // Open settings and verify the correct font family button is active - const settingsButton = screen.getByRole('button', { name: /settings/i }); + const settingsButton = reader.getByRole('button', { name: /settings/i }); await userEvent.click(settingsButton); + const settings = await getReaderOverlay(root); await waitFor(async () => { - await expect(await screen.findByText('Reader Settings')).toBeInTheDocument(); + await expect(await settings.findByText('Reader Settings')).toBeInTheDocument(); }); - const untitledSerifButton = screen.getByRole('button', { name: /untitled/i }); + const untitledSerifButton = settings.getByRole('button', { name: /untitled/i }); await expect(untitledSerifButton).toHaveClass('yv:bg-primary'); - const interButton = screen.getByRole('button', { name: /inter/i }); + const interButton = settings.getByRole('button', { name: /inter/i }); await expect(interButton).not.toHaveClass('yv:bg-primary'); }, }; @@ -1064,16 +1309,17 @@ export const AuthenticatedWithoutAvatar: Story = { ), - play: async () => { + play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); await waitFor( async () => { - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); await expect(userMenuTrigger).toBeInTheDocument(); }, { timeout: 5000 }, ); - const userMenuTrigger = screen.getByTestId('user-menu-trigger'); + const userMenuTrigger = reader.getByTestId('user-menu-trigger'); // No image URL → initials fallback ("Test User" → "TU") inside the avatar circle. await expect(userMenuTrigger.querySelector('img')).not.toBeInTheDocument(); await waitFor(async () => { @@ -1084,8 +1330,9 @@ export const AuthenticatedWithoutAvatar: Story = { await userEvent.click(userMenuTrigger); + const overlays = await getReaderOverlay(root); await waitFor(async () => { - const signOutButton = await screen.findByRole('button', { name: /sign out/i }); + const signOutButton = await overlays.findByRole('button', { name: /sign out/i }); await expect(signOutButton).toBeInTheDocument(); }); }, @@ -1114,21 +1361,22 @@ export const WithoutAuth: Story = { ), play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); // Wait for the Bible content to load await waitFor( async () => { - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); }, { timeout: 5000 }, ); // Verify Bible content is displayed - const verseContainer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(verseContainer).toBeInTheDocument(); // User menu should not be visible when auth is disabled - const userMenuTrigger = canvasElement.querySelector('[data-testid="user-menu-trigger"]'); + const userMenuTrigger = root.querySelector('[data-testid="user-menu-trigger"]'); await expect(userMenuTrigger).not.toBeInTheDocument(); // Chapter picker and version picker should still work @@ -1142,7 +1390,7 @@ export const WithoutAuth: Story = { await expect(versionButton).toBeInTheDocument(); // Settings should still work - const settingsButton = screen.getByRole('button', { name: /settings/i }); + const settingsButton = reader.getByRole('button', { name: /settings/i }); await expect(settingsButton).toBeInTheDocument(); }, }; @@ -1231,17 +1479,18 @@ export const JoshuaIntroChapter: Story = { ), play: async ({ canvasElement }) => { + const { root } = await getReaderStory(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"]'); + const verseContainer = root.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"]')!; + const verseContainer = root.querySelector('[data-slot="yv-bible-renderer"]')!; // The unavailable message should not appear const hasUnavailableText = verseContainer.textContent?.includes('not available'); @@ -1290,8 +1539,9 @@ export const JoshuaIntroChapter: Story = { // Click the footnote and verify the popover shows note content without verse reference await userEvent.click(footnoteButton!); + const overlays = await getReaderOverlay(root); await waitFor(async () => { - const popover = document.querySelector('[role="dialog"]'); + const popover = overlays.queryByRole('dialog'); await expect(popover).toBeInTheDocument(); // Should show note content (e.g., "See Rashi") @@ -1451,9 +1701,10 @@ export const ChapterChangeLoadingOverlay: Story = { ), play: async ({ canvasElement }) => { + const { root } = await getReaderStory(canvasElement); await waitFor( async () => { - const renderer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const renderer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(renderer?.textContent).toContain('JHN.1'); }, { timeout: 5000 }, @@ -1466,16 +1717,16 @@ export const ChapterChangeLoadingOverlay: Story = { const nextButton = chapterPicker.getByRole('button', { name: /next chapter/i }); await userEvent.click(nextButton); - const rendererAfterClick = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const rendererAfterClick = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(rendererAfterClick?.textContent).toContain('JHN.1'); await waitFor( async () => { - const overlay = canvasElement.querySelector('[aria-label="Loading passage"]'); + const overlay = root.querySelector('[aria-label="Loading passage"]'); await expect(overlay).toBeInTheDocument(); await expect(overlay).toHaveAttribute('role', 'status'); - await expect(canvasElement.querySelector('[class*="opacity-40"]')).toBeInTheDocument(); - const renderer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + await expect(root.querySelector('[class*="opacity-40"]')).toBeInTheDocument(); + const renderer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(renderer?.textContent).toContain('JHN.1'); }, { timeout: 2000 }, @@ -1483,17 +1734,15 @@ export const ChapterChangeLoadingOverlay: Story = { await waitFor( async () => { - const renderer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); + const renderer = root.querySelector('[data-slot="yv-bible-renderer"]'); await expect(renderer?.textContent).toContain('JHN.2'); - await expect( - canvasElement.querySelector('[aria-label="Loading passage"]'), - ).not.toBeInTheDocument(); - await expect(canvasElement.querySelector('[class*="opacity-40"]')).not.toBeInTheDocument(); + await expect(root.querySelector('[aria-label="Loading passage"]')).not.toBeInTheDocument(); + await expect(root.querySelector('[class*="opacity-40"]')).not.toBeInTheDocument(); }, { timeout: 5000 }, ); - const scroller = canvasElement.querySelector('main'); + const scroller = root.querySelector('main'); await expect(scroller?.scrollTop).toBe(0); }, }; diff --git a/packages/ui/src/components/bible-reader.test.tsx b/packages/ui/src/components/bible-reader.test.tsx index 86791b32..7330dadb 100644 --- a/packages/ui/src/components/bible-reader.test.tsx +++ b/packages/ui/src/components/bible-reader.test.tsx @@ -13,7 +13,6 @@ import { YouVersionContext, type HookOverrides } from '@youversion/platform-reac import { HookOverrideProvider } from '@/test/hook-overrides'; import { InterfaceDirectionProvider } from '@/lib/direction'; import { ReuseShadowBoundary } from '@/lib/shadow-isolation'; -import { ShadowRootHost } from '@/lib/shadow-root-host'; import { BIBLE_READER_SPACING, BibleReader, @@ -362,36 +361,6 @@ describe('BibleThemeSettingsContent public boundary', () => { }); }); -it('reuses a future reader boundary without nesting a settings host', async () => { - localStorage.clear(); - const user = userEvent.setup(); - const reader = ( - - - - - - ); - const outerView = render({reader}); - const outerHost = await waitFor(() => { - const candidate = outerView.container.querySelector('[data-yv-shadow-host]'); - if (!candidate?.shadowRoot?.querySelector('button[aria-label="Settings"]')) { - throw new Error('reader not mounted in the simulated boundary'); - } - return candidate; - }); - const outerRoot = outerHost.shadowRoot!; - const outerScope = outerRoot.querySelector('[data-yv-shadow-content-wrapper]')!; - - await user.click(within(outerScope).getByRole('button', { name: 'Settings' })); - const settingsControl = await waitFor(() => { - const candidate = outerRoot.querySelector('[data-testid="line-spacing"]'); - if (!candidate) throw new Error('reader settings not rendered in the outer root'); - return candidate; - }); - expect(settingsControl.getRootNode()).toBe(outerRoot); -}); - describe('BibleReader theme settings', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index f7dd5764..b9dccaf6 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -30,6 +30,7 @@ import { } from '@youversion/platform-react-hooks'; import React, { createContext, + useCallback, useContext, useEffect, useLayoutEffect, @@ -64,6 +65,7 @@ import { YouVersionPlatformConfiguration } from '@youversion/platform-core'; import { BibleReaderSearch, type BibleReaderSearchPressData } from './bible-reader-search'; import { useTransientVerseFocus, type VerseFocusRequest } from '@/lib/use-transient-verse-focus'; import { BibleReaderNavigation } from './bible-reader-navigation'; +import { VerseActionPopoverFocusRestoreProvider } from '@/lib/verse-action-popover-focus'; type BibleReaderContextType = { book: string; @@ -762,17 +764,19 @@ function Root({ }; return ( - - -
- {children} -
-
+ + + +
+ {children} +
+
+
); } @@ -874,6 +878,12 @@ function Content() { const [selectedVerses, setSelectedVerses] = useState([]); const [popoverOpen, setPopoverOpen] = useState(false); const [anchorElement, setAnchorElement] = useState(null); + const verseFocusRestoreTargetRef = useRef(null); + const takeVerseFocusRestoreTarget = useCallback(() => { + const target = verseFocusRestoreTargetRef.current; + verseFocusRestoreTargetRef.current = null; + return target; + }, []); const lastSelectionRef = useRef([]); const { @@ -1167,6 +1177,14 @@ function Content() {
{ + let activeElement = event.currentTarget.ownerDocument.activeElement; + while (activeElement?.shadowRoot?.activeElement) { + activeElement = activeElement.shadowRoot.activeElement; + } + verseFocusRestoreTargetRef.current = + activeElement === event.currentTarget.ownerDocument.body ? null : activeElement; + }} >

0} - onOpenChange={handlePopoverOpenChange} - activeHighlights={activeHighlights} - selectedVerses={selectedVerses} - highlightedVerses={highlightedVerses} - highlightsEnabled={highlightsEnabled} - anchorElement={anchorElement} - scrollRoot={scrollContainerRef.current} - onHighlight={handleHighlight} - onClearHighlight={handleClearHighlight} - onCopy={handleCopy} - onShare={handleShare} - theme={background} - /> + + 0} + onOpenChange={handlePopoverOpenChange} + activeHighlights={activeHighlights} + selectedVerses={selectedVerses} + highlightedVerses={highlightedVerses} + highlightsEnabled={highlightsEnabled} + anchorElement={anchorElement} + scrollRoot={scrollContainerRef.current} + onHighlight={handleHighlight} + onClearHighlight={handleClearHighlight} + onCopy={handleCopy} + onShare={handleShare} + theme={background} + /> + )} {yvContext?.authEnabled && } - - - {({ chapterLabel, currentBook, loading }) => ( -
- - + + + + {({ chapterLabel, currentBook, loading }) => ( +
+ + + + + +
+ )} +
+
+
+ + + + + {({ version, loading }) => ( - - -
- )} -
-
- - - - {({ version, loading }) => ( - - )} - - - + )} + + + + {showSearch ? : null} diff --git a/packages/ui/src/components/verse-action-popover.tsx b/packages/ui/src/components/verse-action-popover.tsx index dc262a15..6600138c 100644 --- a/packages/ui/src/components/verse-action-popover.tsx +++ b/packages/ui/src/components/verse-action-popover.tsx @@ -26,6 +26,7 @@ import { import { isDarkHighlightHex } from './verse'; import { useInterfaceDirection } from '@/lib/direction'; import { YvComponentStyles } from '@/lib/yv-styles-components'; +import { useTakeVerseActionPopoverFocusRestoreTarget } from '@/lib/verse-action-popover-focus'; /** Re-export for back-compat; prefer `@/lib/highlight-colors` for new code. */ export { HIGHLIGHT_COLORS, type HighlightColor } from '@/lib/highlight-colors'; @@ -104,6 +105,17 @@ function isVerseEvent(event: Event, owner: HTMLElement): boolean { ); } +function isDurableFocusRestoreTarget( + target: HTMLElement | null | undefined, + ownerDocument?: Document, +): target is HTMLElement { + return Boolean( + target?.isConnected && + (!ownerDocument || target.ownerDocument === ownerDocument) && + !target.closest('[data-yv-shadow-local-overlay], [data-yv-shadow-inline-overlay]'), + ); +} + type VerseActionPopoverProps = { open: boolean; onOpenChange: (open: boolean) => void; @@ -218,6 +230,7 @@ export const VerseActionPopover: FC = ({ const direction = useInterfaceDirection(); const portal = useShadowPortalState({ open, onOpenChange }); const getShadowFocusRestoreTarget = useShadowFocusRestoreTarget(); + const takeReaderFocusRestoreTarget = useTakeVerseActionPopoverFocusRestoreTarget(); // On open, Radix's FocusScope would autofocus the first swatch. Because the bar // opens from a mouse/tap on non-focusable verse text, Chromium treats that @@ -447,8 +460,14 @@ export const VerseActionPopover: FC = ({ const root = getOwnShadowRoot(content); retainOutsideFocusRef.current = false; + const readerFocusRestoreTarget = takeReaderFocusRestoreTarget?.() ?? null; + const readerTarget = + isElementFromOwnerDocument(readerFocusRestoreTarget, content, 'HTMLElement') && + isDurableFocusRestoreTarget(readerFocusRestoreTarget, content.ownerDocument) + ? readerFocusRestoreTarget + : null; const activeElement = root - ? (root.activeElement ?? getShadowFocusRestoreTarget?.()) + ? (readerTarget ?? root.activeElement ?? getShadowFocusRestoreTarget?.()) : documentFocusRestoreTargetRef.current; focusRestoreTargetRef.current = isElementFromOwnerDocument( activeElement, @@ -462,8 +481,12 @@ export const VerseActionPopover: FC = ({ onCloseAutoFocus={(event) => { const root = portal.container ? getOwnShadowRoot(portal.container) : null; const target = focusRestoreTargetRef.current; - const restoreTarget = - target?.isConnected && (!root || target.getRootNode() === root) ? target : null; + const fallbackTarget = getShadowFocusRestoreTarget?.(); + const restoreTarget = isDurableFocusRestoreTarget(target, root?.ownerDocument) + ? target + : isDurableFocusRestoreTarget(fallbackTarget, root?.ownerDocument) + ? fallbackTarget + : null; if (root || restoreTarget) event.preventDefault(); if (!retainOutsideFocusRef.current) restoreTarget?.focus(); retainOutsideFocusRef.current = false; diff --git a/packages/ui/src/lib/verse-action-popover-focus.tsx b/packages/ui/src/lib/verse-action-popover-focus.tsx new file mode 100644 index 00000000..643802dc --- /dev/null +++ b/packages/ui/src/lib/verse-action-popover-focus.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { createContext, useContext, type ReactElement, type ReactNode } from 'react'; + +type TakeFocusRestoreTarget = () => Element | null; + +const VerseActionPopoverFocusRestoreContext = createContext(null); + +export function VerseActionPopoverFocusRestoreProvider({ + takeTarget, + children, +}: { + takeTarget: TakeFocusRestoreTarget; + children: ReactNode; +}): ReactElement { + return ( + + {children} + + ); +} + +export function useTakeVerseActionPopoverFocusRestoreTarget(): TakeFocusRestoreTarget | null { + return useContext(VerseActionPopoverFocusRestoreContext); +}