From c3d5a74d5d4d72913ede5f04bcfd4c2b753b7b72 Mon Sep 17 00:00:00 2001 From: Austin Date: Mon, 5 Oct 2026 09:33:08 -0500 Subject: [PATCH 01/16] feat(ui): isolate Bible reader (YPE-5951) Give BibleReader.Root one automatic shadow boundary, reuse it for reader-owned composition, and preserve focused reader/search behavior across the supported browser matrix. --- .changeset/reader-shadows-wait.md | 2 + docs/shadow-dom-consumer-compatibility.md | 4 + docs/shadow-dom-isolation-plan.md | 43 +++- .../bible-reader-controlled.test.tsx | 8 +- .../bible-reader-navigation.test.tsx | 49 ++-- .../bible-reader-search.stories.tsx | 51 ++-- .../components/bible-reader-search.test.tsx | 47 ++-- .../ui/src/components/bible-reader-search.tsx | 10 +- .../bible-reader-shadow-isolation.test.tsx | 175 ++++++++++++++ .../src/components/bible-reader.stories.tsx | 190 +++++++++++++-- .../ui/src/components/bible-reader.test.tsx | 31 --- packages/ui/src/components/bible-reader.tsx | 226 +++++++++--------- 12 files changed, 606 insertions(+), 230 deletions(-) create mode 100644 .changeset/reader-shadows-wait.md create mode 100644 packages/ui/src/components/bible-reader-shadow-isolation.test.tsx 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/docs/shadow-dom-consumer-compatibility.md b/docs/shadow-dom-consumer-compatibility.md index 7f7e2919..5e356b2c 100644 --- a/docs/shadow-dom-consumer-compatibility.md +++ b/docs/shadow-dom-consumer-compatibility.md @@ -26,6 +26,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 +51,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..4e17d36b 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 @@ -182,6 +181,34 @@ 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 roots 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 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..e50e751d 100644 --- a/packages/ui/src/components/bible-reader-search.stories.tsx +++ b/packages/ui/src/components/bible-reader-search.stories.tsx @@ -1,11 +1,12 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, screen, userEvent, waitFor, within } from 'storybook/test'; +import { expect, fireEvent, screen, userEvent, waitFor, within } from 'storybook/test'; import { BibleReader } from './bible-reader'; import { useState } from 'react'; import { BibleReaderNavigation } from './bible-reader-navigation'; import { Button } from './ui/button'; import { delay, http, HttpResponse } from 'msw'; import { globalHandlers } from '@/test/mocks/handlers'; +import { waitForShadowContent, waitForShadowRoot } from '@/test/storybook-dom'; const meta = { title: 'Components/BibleReaderSearch', @@ -143,7 +144,7 @@ export const SearchFailure: Story = { export const SearchAndReturn: Story = { ...OpenTrending, - tags: ['integration'], + tags: ['integration', 'shadow-dom', 'cross-browser'], parameters: { msw: { handlers: [ @@ -160,30 +161,42 @@ export const SearchAndReturn: Story = { }, }, 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 }); + const root = await waitForShadowRoot(context.canvasElement); + const reader = within(await waitForShadowContent(root)); + await waitFor(() => + expect(reader.getByRole('button', { name: 'Search the Bible' })).toBeVisible(), + ); + await userEvent.click(reader.getByRole('button', { name: 'Search the Bible' })); + const overlay = await waitFor(() => { + const element = root.querySelector('[data-yv-shadow-local-overlay]'); + if (!element) throw new Error('reader search overlay not mounted'); + return element; + }); + const search = within(overlay); + const input = await search.findByRole('textbox', { name: 'Search the Bible' }); + await expect(root.activeElement).toBe(input); + await fireEvent.change(input, { target: { value: 'angels' } }); + await fireEvent.keyDown(input, { key: 'Enter' }); + const result = await search.findByRole('button', { name: /John 1:51/i }); await expect( - screen.queryByRole('button', { name: /^John 1(?:\s|$)/i }), + search.queryByRole('button', { name: /^John 1(?:\s|$)/i }), ).not.toBeInTheDocument(); 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(search.queryByRole('dialog')).not.toBeInTheDocument()); + await waitFor(() => expect(root.activeElement).toBe(root.querySelector('.yv-v[v="51"]'))); + await userEvent.click(reader.getByRole('button', { name: 'Search the Bible' })); + await expect(search.getByRole('textbox')).toHaveValue(''); + const recents = within(search.getByRole('region', { name: 'Recent Searches' })); await waitFor(() => expect(recents.getByRole('button', { name: 'angels' })).toBeVisible()); - await userEvent.click(screen.getByRole('button', { name: 'Close search' })); + await userEvent.click(search.getByRole('button', { name: 'Close search' })); await waitFor(() => - expect(screen.getByRole('button', { name: 'Search the Bible' })).toHaveFocus(), + expect(root.activeElement).toBe(reader.getByRole('button', { 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(reader.getByRole('button', { name: 'Search the Bible' })); + await expect(search.getByRole('dialog')).not.toHaveAttribute('aria-modal', 'true'); + await userEvent.click(reader.getByRole('button', { name: 'Settings' })); await waitFor(() => - expect(screen.queryByRole('textbox', { name: 'Search the Bible' })).not.toBeInTheDocument(), + expect(search.queryByRole('textbox', { name: 'Search the Bible' })).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..780a213e 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); 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..ff5bf52d --- /dev/null +++ b/packages/ui/src/components/bible-reader-shadow-isolation.test.tsx @@ -0,0 +1,175 @@ +/** + * @vitest-environment jsdom + */ +import { act, render, waitFor } from '@testing-library/react'; +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(); +HTMLElement.prototype.scrollTo = vi.fn(); + +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 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(); + expect(recoverableErrors).toEqual([]); + expect(consoleError).not.toHaveBeenCalled(); + } finally { + if (root) await act(async () => root?.unmount()); + consoleError.mockRestore(); + container.remove(); + } + }); + + 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..89a96aa2 100644 --- a/packages/ui/src/components/bible-reader.stories.tsx +++ b/packages/ui/src/components/bible-reader.stories.tsx @@ -1,7 +1,7 @@ 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, type Highlight } from '@youversion/platform-core'; import { delay, http, HttpResponse } from 'msw'; import { useState } from 'react'; import { expect, fn, screen, spyOn, userEvent, waitFor, within } from 'storybook/test'; @@ -14,6 +14,8 @@ import { type BibleReaderRootProps, } from './bible-reader'; import { VerseActionPopover } from './verse-action-popover'; +import { waitForShadowRoot } from '@/test/storybook-dom'; +import { waitForShadowContent } from '@/test/storybook-dom'; async function getPickerQueries(container: ParentNode, triggerName: RegExp | string) { return waitFor(() => { @@ -192,7 +194,7 @@ export const Default: Story = { }; export const VerseSelectionReanchoringDismissalAndFocusRestoration: Story = { - tags: ['integration'], + tags: ['integration', 'shadow-dom', 'cross-browser'], args: { defaultVersionId: 111, lineSpacing: 1.7, @@ -210,45 +212,52 @@ export const VerseSelectionReanchoringDismissalAndFocusRestoration: Story = { }, }, render: (args) => ( -
- +
+
), play: async ({ canvasElement }) => { + const root = await waitForShadowRoot(canvasElement); const firstVerse = await waitFor(() => { - const element = canvasElement.querySelector('.yv-v[v="1"]'); + const element = root.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 secondVerse = root.querySelector('.yv-v[v="2"]'); const secondVerseLabel = secondVerse?.querySelector('.yv-vlbl'); - const outsideControl = canvasElement.querySelector( + const outsideControl = root.querySelector( '[data-testid="outside-reader-control"]', ); if (!secondVerse || !secondVerseLabel || !outsideControl) throw new Error('reader interaction controls not rendered'); const ownerDocument = canvasElement.ownerDocument; - await waitFor(() => expect(ownerDocument.activeElement).toBe(outsideControl)); + await waitFor(() => expect(root.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 expect(ownerDocument.activeElement).toBe(outsideControl); + await waitFor(() => expect(reader.queryByRole('dialog')).not.toBeInTheDocument()); + await expect(root.activeElement).toBe(outsideControl); await userEvent.click(firstVerse); - dialog = await screen.findByRole('dialog'); + dialog = await reader.findByRole('dialog'); const firstDialogRect = dialog.getBoundingClientRect(); await userEvent.click(secondVerseLabel); @@ -267,13 +276,13 @@ export const VerseSelectionReanchoringDismissalAndFocusRestoration: Story = { await expect(firstVerse).not.toHaveClass('yv-v-selected'); await expect(secondVerse).not.toHaveClass('yv-v-selected'); await userEvent.pointer({ keys: '[/MouseLeft]', target: outsideControl }); - await expect(ownerDocument.activeElement).toBe(outsideControl); + await expect(root.activeElement).toBe(outsideControl); // Touch outside events are deferred until click: the original pointerdown's // 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')); @@ -295,7 +304,148 @@ export const VerseSelectionReanchoringDismissalAndFocusRestoration: Story = { await waitFor(() => expect(dialog).not.toBeInTheDocument()); await expect(firstVerse).not.toHaveClass('yv-v-selected'); await expect(secondVerse).not.toHaveClass('yv-v-selected'); - await expect(ownerDocument.activeElement).toBe(outsideControl); + await expect(root.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, + 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; + }); + 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(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); + await waitForShadowContent(root); + 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 expect(yes).toBeInTheDocument(); + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(overlays.queryByRole('button', { name: 'Yes Please' })).toBeNull()); + await expect(root.activeElement).toBe(apply); + }, +}; + +export const SignedInHighlightPermissionOwnership: Story = { + tags: ['integration', 'shadow-dom', 'cross-browser'], + args: { + defaultVersionId: 111, + defaultBook: 'JHN', + defaultChapter: '1', + }, + beforeEach: async () => { + await setupAuthenticatedUser(); + YouVersionPlatformConfiguration.clearGrantedPermissions(); + }, + parameters: { + msw: { + handlers: [ + http.get('*/v1/highlights', () => HttpResponse.json({ data: [], next_page_token: null })), + ...globalHandlers, + ], + }, + }, + render: SignedOutHighlightAuthOwnership.render, + play: async ({ canvasElement }) => { + 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 expect(continueButton).toBeInTheDocument(); + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(overlays.queryByRole('button', { name: 'Continue' })).toBeNull()); + await expect(root.activeElement).toBe(apply); }, }; 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..586e8bbe 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -762,17 +762,19 @@ function Root({ }; return ( - - -
- {children} -
-
+ + + +
+ {children} +
+
+
); } @@ -1645,112 +1647,116 @@ function Toolbar({ > {yvContext?.authEnabled && } - - - {({ chapterLabel, currentBook, loading }) => ( -
- - + + + + {({ chapterLabel, currentBook, loading }) => ( +
+ + + + + +
+ )} +
+
+
+ + + + + {({ version, loading }) => ( - -
-
- )} - - - - - - {({ version, loading }) => ( - - )} - - - + )} + + + + {showSearch ? : null} From 8ed9c6c172a82afdee008930e475c5f7bbe128ab Mon Sep 17 00:00:00 2001 From: Austin Date: Mon, 5 Oct 2026 09:43:45 -0500 Subject: [PATCH 02/16] test(ui): simplify reader isolation evidence (YPE-5951) --- docs/shadow-dom-isolation-plan.md | 4 ++-- packages/ui/src/components/bible-reader.stories.tsx | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/shadow-dom-isolation-plan.md b/docs/shadow-dom-isolation-plan.md index 4e17d36b..aa39ce37 100644 --- a/docs/shadow-dom-isolation-plan.md +++ b/docs/shadow-dom-isolation-plan.md @@ -152,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 diff --git a/packages/ui/src/components/bible-reader.stories.tsx b/packages/ui/src/components/bible-reader.stories.tsx index 89a96aa2..bb77603e 100644 --- a/packages/ui/src/components/bible-reader.stories.tsx +++ b/packages/ui/src/components/bible-reader.stories.tsx @@ -15,7 +15,6 @@ import { } from './bible-reader'; import { VerseActionPopover } from './verse-action-popover'; import { waitForShadowRoot } from '@/test/storybook-dom'; -import { waitForShadowContent } from '@/test/storybook-dom'; async function getPickerQueries(container: ParentNode, triggerName: RegExp | string) { return waitFor(() => { @@ -367,7 +366,6 @@ export const HostileCssConstrainedLayout: Story = { async function openReaderHighlightAuthDialog(canvasElement: HTMLElement) { const root = await waitForShadowRoot(canvasElement); - await waitForShadowContent(root); const verse = await waitFor(() => { const element = root.querySelector('.yv-v[v="1"]'); if (!element) throw new Error('highlight-auth verse not rendered'); @@ -409,7 +407,6 @@ export const SignedOutHighlightAuthOwnership: Story = { const dialog = yes.closest('[role="dialog"]'); if (!dialog) throw new Error('sign-in dialog not rendered'); await expect(dialog.getRootNode()).toBe(root); - await expect(yes).toBeInTheDocument(); await userEvent.keyboard('{Escape}'); await waitFor(() => expect(overlays.queryByRole('button', { name: 'Yes Please' })).toBeNull()); await expect(root.activeElement).toBe(apply); @@ -442,7 +439,6 @@ export const SignedInHighlightPermissionOwnership: Story = { const dialog = continueButton.closest('[role="dialog"]'); if (!dialog) throw new Error('permission dialog not rendered'); await expect(dialog.getRootNode()).toBe(root); - await expect(continueButton).toBeInTheDocument(); await userEvent.keyboard('{Escape}'); await waitFor(() => expect(overlays.queryByRole('button', { name: 'Continue' })).toBeNull()); await expect(root.activeElement).toBe(apply); From 815f48112c48ac81cadd226b8068fc9bbdbb0fa8 Mon Sep 17 00:00:00 2001 From: Austin Date: Mon, 5 Oct 2026 10:57:03 -0500 Subject: [PATCH 03/16] fix(ui): harden reader isolation evidence (YPE-5951) --- docs/shadow-dom-isolation-plan.md | 10 + .../bible-reader-search.stories.tsx | 131 ++++---- .../bible-reader-shadow-isolation.test.tsx | 56 +++- .../src/components/bible-reader.stories.tsx | 283 +++++++++++------- 4 files changed, 323 insertions(+), 157 deletions(-) diff --git a/docs/shadow-dom-isolation-plan.md b/docs/shadow-dom-isolation-plan.md index aa39ce37..587b3279 100644 --- a/docs/shadow-dom-isolation-plan.md +++ b/docs/shadow-dom-isolation-plan.md @@ -200,6 +200,16 @@ 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 host +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 diff --git a/packages/ui/src/components/bible-reader-search.stories.tsx b/packages/ui/src/components/bible-reader-search.stories.tsx index e50e751d..187cf217 100644 --- a/packages/ui/src/components/bible-reader-search.stories.tsx +++ b/packages/ui/src/components/bible-reader-search.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, fireEvent, 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 { BibleReaderNavigation } from './bible-reader-navigation'; @@ -20,6 +20,31 @@ 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.querySelector('[data-yv-shadow-local-overlay]'); + 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) await fireEvent.keyDown(input, { key: 'Enter' }); +} + export const OpenTrending: Story = { tags: ['integration'], args: { @@ -36,23 +61,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(); @@ -86,25 +113,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)'); @@ -131,14 +158,15 @@ 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(); }, }; @@ -161,22 +189,15 @@ export const SearchAndReturn: Story = { }, }, play: async (context) => { - const root = await waitForShadowRoot(context.canvasElement); - const reader = within(await waitForShadowContent(root)); + const { root, reader } = await getReaderStory(context.canvasElement); await waitFor(() => expect(reader.getByRole('button', { name: 'Search the Bible' })).toBeVisible(), ); await userEvent.click(reader.getByRole('button', { name: 'Search the Bible' })); - const overlay = await waitFor(() => { - const element = root.querySelector('[data-yv-shadow-local-overlay]'); - if (!element) throw new Error('reader search overlay not mounted'); - return element; - }); - const search = within(overlay); + const { search } = await getOpenSearch(context.canvasElement); const input = await search.findByRole('textbox', { name: 'Search the Bible' }); await expect(root.activeElement).toBe(input); - await fireEvent.change(input, { target: { value: 'angels' } }); - await fireEvent.keyDown(input, { key: 'Enter' }); + await typeInShadowInput(input, 'angels{Enter}'); const result = await search.findByRole('button', { name: /John 1:51/i }); await expect( search.queryByRole('button', { name: /^John 1(?:\s|$)/i }), @@ -216,13 +237,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'); }, }; @@ -242,9 +264,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(); }, }; @@ -272,25 +295,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(); }, }; @@ -322,23 +346,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-shadow-isolation.test.tsx b/packages/ui/src/components/bible-reader-shadow-isolation.test.tsx index ff5bf52d..669a5b9a 100644 --- a/packages/ui/src/components/bible-reader-shadow-isolation.test.tsx +++ b/packages/ui/src/components/bible-reader-shadow-isolation.test.tsx @@ -2,6 +2,7 @@ * @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'; @@ -15,7 +16,6 @@ import { BibleReaderSearch } from './bible-reader-search'; import { ProfileAvatar } from './profile-avatar'; installResizeObserverStub(); -HTMLElement.prototype.scrollTo = vi.fn(); const books: BibleBook[] = [ { @@ -94,6 +94,27 @@ function withOverrides(element: ReactElement): ReactElement { 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( @@ -137,12 +158,45 @@ describe('BibleReader.Root public shadow boundary', () => { 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'); + } } }); diff --git a/packages/ui/src/components/bible-reader.stories.tsx b/packages/ui/src/components/bible-reader.stories.tsx index bb77603e..57d90b71 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 { YouVersionPlatformConfiguration, 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,7 +19,22 @@ import { type BibleReaderRootProps, } from './bible-reader'; import { VerseActionPopover } from './verse-action-popover'; -import { waitForShadowRoot } from '@/test/storybook-dom'; +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(() => { @@ -138,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'); @@ -179,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( @@ -213,7 +235,7 @@ export const VerseSelectionReanchoringDismissalAndFocusRestoration: Story = { render: (args) => (
- @@ -230,14 +252,14 @@ export const VerseSelectionReanchoringDismissalAndFocusRestoration: Story = { }); const secondVerse = root.querySelector('.yv-v[v="2"]'); const secondVerseLabel = secondVerse?.querySelector('.yv-vlbl'); - const outsideControl = root.querySelector( - '[data-testid="outside-reader-control"]', + const readerChildControl = root.querySelector( + '[data-testid="reader-child-control"]', ); - if (!secondVerse || !secondVerseLabel || !outsideControl) + if (!secondVerse || !secondVerseLabel || !readerChildControl) throw new Error('reader interaction controls not rendered'); const ownerDocument = canvasElement.ownerDocument; - await waitFor(() => expect(root.activeElement).toBe(outsideControl)); + await waitFor(() => expect(root.activeElement).toBe(readerChildControl)); await userEvent.click(firstVerse); const overlay = await waitFor(() => { const element = root.querySelector('[data-yv-shadow-local-overlay]'); @@ -253,7 +275,7 @@ export const VerseSelectionReanchoringDismissalAndFocusRestoration: Story = { await userEvent.keyboard('{Escape}'); await waitFor(() => expect(reader.queryByRole('dialog')).not.toBeInTheDocument()); - await expect(root.activeElement).toBe(outsideControl); + await expect(root.activeElement).toBe(readerChildControl); await userEvent.click(firstVerse); dialog = await reader.findByRole('dialog'); @@ -270,12 +292,12 @@ export const VerseSelectionReanchoringDismissalAndFocusRestoration: Story = { void expect(movedInline || movedBlock).toBe(true); }); - await userEvent.pointer({ keys: '[MouseLeft>]', target: outsideControl }); + await userEvent.pointer({ keys: '[MouseLeft>]', target: readerChildControl }); await waitFor(() => expect(dialog).not.toBeInTheDocument()); await expect(firstVerse).not.toHaveClass('yv-v-selected'); await expect(secondVerse).not.toHaveClass('yv-v-selected'); - await userEvent.pointer({ keys: '[/MouseLeft]', target: outsideControl }); - await expect(root.activeElement).toBe(outsideControl); + await userEvent.pointer({ keys: '[/MouseLeft]', target: readerChildControl }); + await expect(root.activeElement).toBe(readerChildControl); // Touch outside events are deferred until click: the original pointerdown's // composed path is already empty when Radix asks whether to dismiss. @@ -297,13 +319,13 @@ export const VerseSelectionReanchoringDismissalAndFocusRestoration: Story = { const movedBlock = Math.abs(nextRect.top - touchDialogRect.top) > 8; void expect(movedInline || movedBlock).toBe(true); }); - await touchUser.pointer({ keys: '[TouchA>]', target: outsideControl }); + await touchUser.pointer({ keys: '[TouchA>]', target: readerChildControl }); await expect(dialog).toHaveAttribute('data-state', 'open'); - await touchUser.pointer({ keys: '[/TouchA]', target: outsideControl }); + await touchUser.pointer({ keys: '[/TouchA]', target: readerChildControl }); await waitFor(() => expect(dialog).not.toBeInTheDocument()); await expect(firstVerse).not.toHaveClass('yv-v-selected'); await expect(secondVerse).not.toHaveClass('yv-v-selected'); - await expect(root.activeElement).toBe(outsideControl); + await expect(root.activeElement).toBe(readerChildControl); }, }; @@ -357,6 +379,7 @@ export const HostileCssConstrainedLayout: Story = { 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); @@ -409,10 +432,37 @@ export const SignedOutHighlightAuthOwnership: Story = { 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: { @@ -420,10 +470,6 @@ export const SignedInHighlightPermissionOwnership: Story = { defaultBook: 'JHN', defaultChapter: '1', }, - beforeEach: async () => { - await setupAuthenticatedUser(); - YouVersionPlatformConfiguration.clearGrantedPermissions(); - }, parameters: { msw: { handlers: [ @@ -432,8 +478,17 @@ export const SignedInHighlightPermissionOwnership: Story = { ], }, }, - render: SignedOutHighlightAuthOwnership.render, + 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"]'); @@ -441,6 +496,8 @@ export const SignedInHighlightPermissionOwnership: Story = { 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); }, }; @@ -492,13 +549,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); @@ -507,24 +565,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'); }); }, @@ -559,9 +618,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', ); @@ -601,9 +661,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!; }); @@ -643,6 +704,7 @@ export const ForcedRtlChromeGeometry: Story = { ), play: async ({ canvasElement }) => { + const { root, reader } = await getReaderStory(canvasElement); const { root: chapterRoot, picker: chapterPicker, @@ -651,13 +713,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, ); @@ -709,8 +771,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'); @@ -819,9 +881,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 }, @@ -849,9 +912,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 }, @@ -896,15 +960,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 () => { @@ -916,14 +981,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 () => { @@ -931,7 +997,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); @@ -962,32 +1028,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(); }); @@ -1009,26 +1077,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(); @@ -1056,16 +1126,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"]'); @@ -1074,16 +1145,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(); }); }, @@ -1110,16 +1182,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 () => { @@ -1154,18 +1227,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( @@ -1176,17 +1248,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'); }, }; @@ -1210,16 +1283,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 () => { @@ -1230,8 +1304,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(); }); }, @@ -1260,21 +1335,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 @@ -1288,7 +1364,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(); }, }; @@ -1377,17 +1453,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'); @@ -1436,8 +1513,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") @@ -1597,9 +1675,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 }, @@ -1612,16 +1691,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 }, @@ -1629,17 +1708,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); }, }; From 589b0e707a47c9388c69dfa61de122565d082e02 Mon Sep 17 00:00:00 2001 From: Austin Date: Mon, 5 Oct 2026 11:21:12 -0500 Subject: [PATCH 04/16] test(ui): stabilize reader search browser wait (YPE-5951) --- .../bible-reader-search.stories.tsx | 15 ++++++--- .../src/components/bible-reader.stories.tsx | 33 ++++++++++++------- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/packages/ui/src/components/bible-reader-search.stories.tsx b/packages/ui/src/components/bible-reader-search.stories.tsx index 187cf217..5116f81e 100644 --- a/packages/ui/src/components/bible-reader-search.stories.tsx +++ b/packages/ui/src/components/bible-reader-search.stories.tsx @@ -42,7 +42,14 @@ async function typeInShadowInput(input: HTMLElement, text: string) { // 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) await fireEvent.keyDown(input, { key: 'Enter' }); + 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'); + const root = within(inputRoot); + await waitFor(() => expect(root.getByRole('button', { name: 'Clear search' })).toBeVisible()); + await fireEvent.keyDown(input, { key: 'Enter' }); + } } export const OpenTrending: Story = { @@ -197,8 +204,8 @@ export const SearchAndReturn: Story = { const { search } = await getOpenSearch(context.canvasElement); const input = await search.findByRole('textbox', { name: 'Search the Bible' }); await expect(root.activeElement).toBe(input); - await typeInShadowInput(input, 'angels{Enter}'); - const result = await search.findByRole('button', { name: /John 1:51/i }); + await typeInShadowInput(input, 'love{Enter}'); + const result = await search.findByRole('button', { name: /John 1:51/i }, { timeout: 10000 }); await expect( search.queryByRole('button', { name: /^John 1(?:\s|$)/i }), ).not.toBeInTheDocument(); @@ -208,7 +215,7 @@ export const SearchAndReturn: Story = { await userEvent.click(reader.getByRole('button', { name: 'Search the Bible' })); await expect(search.getByRole('textbox')).toHaveValue(''); const recents = within(search.getByRole('region', { name: 'Recent Searches' })); - await waitFor(() => expect(recents.getByRole('button', { name: 'angels' })).toBeVisible()); + await waitFor(() => expect(recents.getByRole('button', { name: 'love' })).toBeVisible()); await userEvent.click(search.getByRole('button', { name: 'Close search' })); await waitFor(() => expect(root.activeElement).toBe(reader.getByRole('button', { name: 'Search the Bible' })), diff --git a/packages/ui/src/components/bible-reader.stories.tsx b/packages/ui/src/components/bible-reader.stories.tsx index 57d90b71..8fd52d93 100644 --- a/packages/ui/src/components/bible-reader.stories.tsx +++ b/packages/ui/src/components/bible-reader.stories.tsx @@ -245,11 +245,14 @@ export const VerseSelectionReanchoringDismissalAndFocusRestoration: Story = { ), play: async ({ canvasElement }) => { 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; - }); + 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 readerChildControl = root.querySelector( @@ -293,7 +296,7 @@ export const VerseSelectionReanchoringDismissalAndFocusRestoration: Story = { }); await userEvent.pointer({ keys: '[MouseLeft>]', target: readerChildControl }); - 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: readerChildControl }); @@ -322,7 +325,7 @@ export const VerseSelectionReanchoringDismissalAndFocusRestoration: Story = { await touchUser.pointer({ keys: '[TouchA>]', target: readerChildControl }); await expect(dialog).toHaveAttribute('data-state', 'open'); await touchUser.pointer({ keys: '[/TouchA]', target: readerChildControl }); - 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(root.activeElement).toBe(readerChildControl); @@ -338,6 +341,9 @@ export const HostileCssConstrainedLayout: Story = { background: 'dark', fontFamily: INTER_FONT, fontSize: 18, + onFontFamilyChange: fn(), + onFontSizeChange: fn(), + onChangeLineSpacing: fn(), scriptureDirection: 'ltr', }, globals: { @@ -363,11 +369,14 @@ export const HostileCssConstrainedLayout: Story = { '[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; - }); + 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) { From 3cf3249a8fcbf02dba7b9554abb2ff4968d7f4cc Mon Sep 17 00:00:00 2001 From: Austin Date: Mon, 5 Oct 2026 11:58:37 -0500 Subject: [PATCH 05/16] fix(ui): preserve reader external focus (YPE-5951) --- .size-limit.js | 2 +- .../bible-reader-search.stories.tsx | 5 +- .../src/components/bible-reader.stories.tsx | 47 +++++++++++++------ packages/ui/src/components/bible-reader.tsx | 10 ++++ .../src/components/verse-action-popover.tsx | 9 +++- 5 files changed, 53 insertions(+), 20 deletions(-) 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/packages/ui/src/components/bible-reader-search.stories.tsx b/packages/ui/src/components/bible-reader-search.stories.tsx index 5116f81e..730ab873 100644 --- a/packages/ui/src/components/bible-reader-search.stories.tsx +++ b/packages/ui/src/components/bible-reader-search.stories.tsx @@ -46,8 +46,9 @@ async function typeInShadowInput(input: HTMLElement, text: string) { // 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'); - const root = within(inputRoot); - await waitFor(() => expect(root.getByRole('button', { name: 'Clear search' })).toBeVisible()); + await waitFor(() => + expect(inputRoot.querySelector('button[aria-label="Clear search"]')).toBeVisible(), + ); await fireEvent.keyDown(input, { key: 'Enter' }); } } diff --git a/packages/ui/src/components/bible-reader.stories.tsx b/packages/ui/src/components/bible-reader.stories.tsx index 8fd52d93..6bca42d9 100644 --- a/packages/ui/src/components/bible-reader.stories.tsx +++ b/packages/ui/src/components/bible-reader.stories.tsx @@ -233,11 +233,11 @@ export const VerseSelectionReanchoringDismissalAndFocusRestoration: Story = { }, }, render: (args) => ( -
+
+ - @@ -255,14 +255,14 @@ export const VerseSelectionReanchoringDismissalAndFocusRestoration: Story = { ); const secondVerse = root.querySelector('.yv-v[v="2"]'); const secondVerseLabel = secondVerse?.querySelector('.yv-vlbl'); - const readerChildControl = root.querySelector( - '[data-testid="reader-child-control"]', + const outsideControl = canvasElement.querySelector( + '[data-testid="outside-reader-control"]', ); - if (!secondVerse || !secondVerseLabel || !readerChildControl) + if (!secondVerse || !secondVerseLabel || !outsideControl) throw new Error('reader interaction controls not rendered'); const ownerDocument = canvasElement.ownerDocument; - await waitFor(() => expect(root.activeElement).toBe(readerChildControl)); + await waitFor(() => expect(ownerDocument.activeElement).toBe(outsideControl)); await userEvent.click(firstVerse); const overlay = await waitFor(() => { const element = root.querySelector('[data-yv-shadow-local-overlay]'); @@ -278,7 +278,24 @@ export const VerseSelectionReanchoringDismissalAndFocusRestoration: Story = { await userEvent.keyboard('{Escape}'); await waitFor(() => expect(reader.queryByRole('dialog')).not.toBeInTheDocument()); - await expect(root.activeElement).toBe(readerChildControl); + 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 reader.findByRole('dialog'); @@ -295,12 +312,12 @@ export const VerseSelectionReanchoringDismissalAndFocusRestoration: Story = { void expect(movedInline || movedBlock).toBe(true); }); - await userEvent.pointer({ keys: '[MouseLeft>]', target: readerChildControl }); + await userEvent.pointer({ keys: '[MouseLeft>]', target: outsideControl }); 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: readerChildControl }); - await expect(root.activeElement).toBe(readerChildControl); + await userEvent.pointer({ keys: '[/MouseLeft]', target: outsideControl }); + await expect(ownerDocument.activeElement).toBe(outsideControl); // Touch outside events are deferred until click: the original pointerdown's // composed path is already empty when Radix asks whether to dismiss. @@ -322,13 +339,13 @@ export const VerseSelectionReanchoringDismissalAndFocusRestoration: Story = { const movedBlock = Math.abs(nextRect.top - touchDialogRect.top) > 8; void expect(movedInline || movedBlock).toBe(true); }); - await touchUser.pointer({ keys: '[TouchA>]', target: readerChildControl }); + await touchUser.pointer({ keys: '[TouchA>]', target: outsideControl }); await expect(dialog).toHaveAttribute('data-state', 'open'); - await touchUser.pointer({ keys: '[/TouchA]', target: readerChildControl }); + await touchUser.pointer({ keys: '[/TouchA]', target: outsideControl }); 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(root.activeElement).toBe(readerChildControl); + await expect(ownerDocument.activeElement).toBe(outsideControl); }, }; diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index 586e8bbe..56b17368 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -876,6 +876,7 @@ function Content() { const [selectedVerses, setSelectedVerses] = useState([]); const [popoverOpen, setPopoverOpen] = useState(false); const [anchorElement, setAnchorElement] = useState(null); + const verseFocusRestoreTargetRef = useRef(null); const lastSelectionRef = useRef([]); const { @@ -1169,6 +1170,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; + }} >

; anchorElement?: HTMLElement | null; + /** Focused host element captured before a shadow-local verse interaction. */ + focusRestoreTarget?: Element | null; /** * The reader's scroll container. When provided, the bar docks to the edge of * this element that the anchored verse scrolls out through, so the actions stay @@ -206,6 +208,7 @@ export const VerseActionPopover: FC = ({ selectedVerses, highlightedVerses, anchorElement, + focusRestoreTarget, scrollRoot, highlightsEnabled = true, onHighlight, @@ -448,7 +451,7 @@ export const VerseActionPopover: FC = ({ const root = getOwnShadowRoot(content); retainOutsideFocusRef.current = false; const activeElement = root - ? (root.activeElement ?? getShadowFocusRestoreTarget?.()) + ? (root.activeElement ?? focusRestoreTarget ?? getShadowFocusRestoreTarget?.()) : documentFocusRestoreTargetRef.current; focusRestoreTargetRef.current = isElementFromOwnerDocument( activeElement, @@ -463,7 +466,9 @@ export const VerseActionPopover: FC = ({ const root = portal.container ? getOwnShadowRoot(portal.container) : null; const target = focusRestoreTargetRef.current; const restoreTarget = - target?.isConnected && (!root || target.getRootNode() === root) ? target : null; + target?.isConnected && (!root || target.ownerDocument === root.ownerDocument) + ? target + : null; if (root || restoreTarget) event.preventDefault(); if (!retainOutsideFocusRef.current) restoreTarget?.focus(); retainOutsideFocusRef.current = false; From 83f0aa9318242686f0d016bf290f02ea3f5ddbc1 Mon Sep 17 00:00:00 2001 From: Austin Date: Mon, 5 Oct 2026 12:29:48 -0500 Subject: [PATCH 06/16] test(ui): scope reader search browser evidence (YPE-5951) --- .../bible-reader-search.stories.tsx | 53 +++++-------------- 1 file changed, 14 insertions(+), 39 deletions(-) diff --git a/packages/ui/src/components/bible-reader-search.stories.tsx b/packages/ui/src/components/bible-reader-search.stories.tsx index 730ab873..53de84a0 100644 --- a/packages/ui/src/components/bible-reader-search.stories.tsx +++ b/packages/ui/src/components/bible-reader-search.stories.tsx @@ -29,7 +29,9 @@ async function getReaderStory(canvasElement: HTMLElement) { async function getOpenSearch(canvasElement: HTMLElement) { const { root, reader } = await getReaderStory(canvasElement); const overlay = await waitFor(() => { - const element = root.querySelector('[data-yv-shadow-local-overlay]'); + 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; }); @@ -178,54 +180,27 @@ export const SearchFailure: Story = { }, }; -export const SearchAndReturn: Story = { +export const ToolbarSearchLifecycle: Story = { ...OpenTrending, tags: ['integration', 'shadow-dom', 'cross-browser'], - 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, - ], - }, - }, play: async (context) => { const { root, reader } = await getReaderStory(context.canvasElement); - await waitFor(() => - expect(reader.getByRole('button', { name: 'Search the Bible' })).toBeVisible(), - ); - await userEvent.click(reader.getByRole('button', { name: 'Search the Bible' })); + const trigger = await reader.findByRole('button', { name: 'Search the Bible' }); + await userEvent.click(trigger); const { search } = await getOpenSearch(context.canvasElement); const input = await search.findByRole('textbox', { name: 'Search the Bible' }); await expect(root.activeElement).toBe(input); - await typeInShadowInput(input, 'love{Enter}'); - const result = await search.findByRole('button', { name: /John 1:51/i }, { timeout: 10000 }); - await expect( - search.queryByRole('button', { name: /^John 1(?:\s|$)/i }), - ).not.toBeInTheDocument(); - await userEvent.click(result); - await waitFor(() => expect(search.queryByRole('dialog')).not.toBeInTheDocument()); - await waitFor(() => expect(root.activeElement).toBe(root.querySelector('.yv-v[v="51"]'))); - await userEvent.click(reader.getByRole('button', { name: 'Search the Bible' })); - await expect(search.getByRole('textbox')).toHaveValue(''); - const recents = within(search.getByRole('region', { name: 'Recent Searches' })); - await waitFor(() => expect(recents.getByRole('button', { name: 'love' })).toBeVisible()); - await userEvent.click(search.getByRole('button', { name: 'Close search' })); - await waitFor(() => - expect(root.activeElement).toBe(reader.getByRole('button', { name: 'Search the Bible' })), - ); - await userEvent.click(reader.getByRole('button', { name: 'Search the Bible' })); await expect(search.getByRole('dialog')).not.toHaveAttribute('aria-modal', 'true'); + await userEvent.click(search.getByRole('button', { name: 'Close search' })); + await waitFor(() => expect(search.queryByRole('dialog')).not.toBeInTheDocument()); + await waitFor(() => expect(root.activeElement).toBe(trigger)); + await userEvent.click(trigger); + const { search: reopenedSearch } = await getOpenSearch(context.canvasElement); await userEvent.click(reader.getByRole('button', { name: 'Settings' })); await waitFor(() => - expect(search.queryByRole('textbox', { name: 'Search the Bible' })).not.toBeInTheDocument(), + expect( + reopenedSearch.queryByRole('textbox', { name: 'Search the Bible' }), + ).not.toBeInTheDocument(), ); }, }; From 6cd35eea696cb0983f5e1ebfde3e25d55d44eaf2 Mon Sep 17 00:00:00 2001 From: Austin Date: Mon, 5 Oct 2026 13:06:43 -0500 Subject: [PATCH 07/16] fix(ui): keep reader focus handoff private (YPE-5951) --- packages/ui/src/components/bible-reader.tsx | 34 ++++++++++--------- .../src/components/verse-action-popover.tsx | 9 ++--- .../ui/src/lib/verse-action-popover-focus.tsx | 23 +++++++++++++ 3 files changed, 46 insertions(+), 20 deletions(-) create mode 100644 packages/ui/src/lib/verse-action-popover-focus.tsx diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index 56b17368..83236f0f 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -64,6 +64,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; @@ -1240,22 +1241,23 @@ function Content() { all — two action surfaces would stack. Selection, painting, and every payload above are untouched. */} {verseActions !== 'none' && ( - 0} - onOpenChange={handlePopoverOpenChange} - activeHighlights={activeHighlights} - selectedVerses={selectedVerses} - highlightedVerses={highlightedVerses} - highlightsEnabled={highlightsEnabled} - anchorElement={anchorElement} - focusRestoreTarget={verseFocusRestoreTargetRef.current} - 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} + /> + )} ; anchorElement?: HTMLElement | null; - /** Focused host element captured before a shadow-local verse interaction. */ - focusRestoreTarget?: Element | null; /** * The reader's scroll container. When provided, the bar docks to the edge of * this element that the anchored verse scrolls out through, so the actions stay @@ -208,7 +207,6 @@ export const VerseActionPopover: FC = ({ selectedVerses, highlightedVerses, anchorElement, - focusRestoreTarget, scrollRoot, highlightsEnabled = true, onHighlight, @@ -221,6 +219,7 @@ export const VerseActionPopover: FC = ({ const direction = useInterfaceDirection(); const portal = useShadowPortalState({ open, onOpenChange }); const getShadowFocusRestoreTarget = useShadowFocusRestoreTarget(); + const readerFocusRestoreTarget = useVerseActionPopoverFocusRestoreTarget(); // 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 @@ -451,7 +450,9 @@ export const VerseActionPopover: FC = ({ const root = getOwnShadowRoot(content); retainOutsideFocusRef.current = false; const activeElement = root - ? (root.activeElement ?? focusRestoreTarget ?? getShadowFocusRestoreTarget?.()) + ? (root.activeElement ?? + readerFocusRestoreTarget ?? + getShadowFocusRestoreTarget?.()) : documentFocusRestoreTargetRef.current; focusRestoreTargetRef.current = isElementFromOwnerDocument( activeElement, 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..b3337a31 --- /dev/null +++ b/packages/ui/src/lib/verse-action-popover-focus.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { createContext, useContext, type ReactElement, type ReactNode } from 'react'; + +const VerseActionPopoverFocusRestoreContext = createContext(null); + +export function VerseActionPopoverFocusRestoreProvider({ + target, + children, +}: { + target: Element | null; + children: ReactNode; +}): ReactElement { + return ( + + {children} + + ); +} + +export function useVerseActionPopoverFocusRestoreTarget(): Element | null { + return useContext(VerseActionPopoverFocusRestoreContext); +} From 31a171df9dc389a61b4770306e736352d2db3aed Mon Sep 17 00:00:00 2001 From: Austin Date: Mon, 5 Oct 2026 13:12:32 -0500 Subject: [PATCH 08/16] test(ui): restore reader search focus journey (YPE-5951) --- .../bible-reader-search.stories.tsx | 95 +++++++++++++++++-- 1 file changed, 87 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/bible-reader-search.stories.tsx b/packages/ui/src/components/bible-reader-search.stories.tsx index 53de84a0..bee1f48e 100644 --- a/packages/ui/src/components/bible-reader-search.stories.tsx +++ b/packages/ui/src/components/bible-reader-search.stories.tsx @@ -1,11 +1,18 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, fireEvent, userEvent, waitFor, within } from 'storybook/test'; import { BibleReader } from './bible-reader'; -import { useState } from 'react'; +import { useContext, useState } 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 = { @@ -180,26 +187,98 @@ export const SearchFailure: Story = { }, }; -export const ToolbarSearchLifecycle: Story = { +const searchResult: BibleSearchResult = { + id: 'JHN.1.51', + book: 'JHN', + chapter: '1', + verses: [51], +}; + +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 SearchJourneyToolbar() { + const context = useContext(YouVersionContext); + if (context === null) throw new Error('Search story requires YouVersionProvider'); + return ( + ({ + passage: mockPassages['JHN.1.51'], + loading: false, + error: null, + refetch: () => undefined, + }), + }, + }} + > + + + ); +} + +export const SearchAndReturn: Story = { ...OpenTrending, tags: ['integration', 'shadow-dom', 'cross-browser'], + render: (args) => ( +
+ + + + +
+ ), play: async (context) => { 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); - const input = await search.findByRole('textbox', { name: 'Search the Bible' }); - await expect(root.activeElement).toBe(input); - await expect(search.getByRole('dialog')).not.toHaveAttribute('aria-modal', 'true'); - await userEvent.click(search.getByRole('button', { name: 'Close search' })); + 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/i }, { timeout: 20000 }); + await userEvent.click(result); await waitFor(() => expect(search.queryByRole('dialog')).not.toBeInTheDocument()); - await waitFor(() => expect(root.activeElement).toBe(trigger)); + await waitFor(() => expect(root.activeElement).toBe(root.querySelector('.yv-v[v="51"]'))); await userEvent.click(trigger); const { search: reopenedSearch } = await getOpenSearch(context.canvasElement); + await expect(reopenedSearch.getByRole('textbox')).toHaveValue(''); + const recents = within(reopenedSearch.getByRole('region', { name: 'Recent Searches' })); + await expect(recents.getByRole('button', { name: 'love' })).toBeVisible(); + await expect(reopenedSearch.getByRole('dialog')).not.toHaveAttribute('aria-modal', 'true'); + await userEvent.click(reopenedSearch.getByRole('button', { name: 'Close search' })); + await waitFor(() => expect(root.activeElement).toBe(trigger)); + await userEvent.click(trigger); + const { search: finalSearch } = await getOpenSearch(context.canvasElement); await userEvent.click(reader.getByRole('button', { name: 'Settings' })); await waitFor(() => expect( - reopenedSearch.queryByRole('textbox', { name: 'Search the Bible' }), + finalSearch.queryByRole('textbox', { name: 'Search the Bible' }), ).not.toBeInTheDocument(), ); }, From 45d3d256f630c160f6e5bf03a2087ea47b09f1f1 Mon Sep 17 00:00:00 2001 From: Austin Date: Mon, 5 Oct 2026 13:26:42 -0500 Subject: [PATCH 09/16] test(ui): focus reader search boundary evidence (YPE-5951) --- .../bible-reader-search.stories.tsx | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/components/bible-reader-search.stories.tsx b/packages/ui/src/components/bible-reader-search.stories.tsx index bee1f48e..0de3fa96 100644 --- a/packages/ui/src/components/bible-reader-search.stories.tsx +++ b/packages/ui/src/components/bible-reader-search.stories.tsx @@ -261,26 +261,14 @@ export const SearchAndReturn: Story = { ); 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/i }, { timeout: 20000 }); + const result = await search.findByRole( + 'button', + { name: /(?:John 1:51|JHN\.1\.51)/i }, + { timeout: 20000 }, + ); await userEvent.click(result); await waitFor(() => expect(search.queryByRole('dialog')).not.toBeInTheDocument()); await waitFor(() => expect(root.activeElement).toBe(root.querySelector('.yv-v[v="51"]'))); - await userEvent.click(trigger); - const { search: reopenedSearch } = await getOpenSearch(context.canvasElement); - await expect(reopenedSearch.getByRole('textbox')).toHaveValue(''); - const recents = within(reopenedSearch.getByRole('region', { name: 'Recent Searches' })); - await expect(recents.getByRole('button', { name: 'love' })).toBeVisible(); - await expect(reopenedSearch.getByRole('dialog')).not.toHaveAttribute('aria-modal', 'true'); - await userEvent.click(reopenedSearch.getByRole('button', { name: 'Close search' })); - await waitFor(() => expect(root.activeElement).toBe(trigger)); - await userEvent.click(trigger); - const { search: finalSearch } = await getOpenSearch(context.canvasElement); - await userEvent.click(reader.getByRole('button', { name: 'Settings' })); - await waitFor(() => - expect( - finalSearch.queryByRole('textbox', { name: 'Search the Bible' }), - ).not.toBeInTheDocument(), - ); }, }; From a4b8cc86de895fc238744dae607414e128124e77 Mon Sep 17 00:00:00 2001 From: Austin Date: Tue, 6 Oct 2026 08:46:24 -0500 Subject: [PATCH 10/16] fix(ui): restore reader pointer-time focus (YPE-5951) --- .../bible-reader-search.stories.tsx | 30 +++++++++++++++++-- .../src/components/verse-action-popover.tsx | 4 +-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/bible-reader-search.stories.tsx b/packages/ui/src/components/bible-reader-search.stories.tsx index 0de3fa96..c83087db 100644 --- a/packages/ui/src/components/bible-reader-search.stories.tsx +++ b/packages/ui/src/components/bible-reader-search.stories.tsx @@ -244,7 +244,10 @@ export const SearchAndReturn: Story = { ...OpenTrending, tags: ['integration', 'shadow-dom', 'cross-browser'], render: (args) => ( -
+
+ @@ -268,7 +271,30 @@ export const SearchAndReturn: Story = { ); await userEvent.click(result); await waitFor(() => expect(search.queryByRole('dialog')).not.toBeInTheDocument()); - await waitFor(() => expect(root.activeElement).toBe(root.querySelector('.yv-v[v="51"]'))); + const destinationVerse = root.querySelector('.yv-v[v="51"]'); + await waitFor(() => expect(root.activeElement).toBe(destinationVerse)); + + 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 (!destinationVerse || !outsideControl) throw new Error('focus regression controls 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); }, }; diff --git a/packages/ui/src/components/verse-action-popover.tsx b/packages/ui/src/components/verse-action-popover.tsx index 0c56d49f..e2910164 100644 --- a/packages/ui/src/components/verse-action-popover.tsx +++ b/packages/ui/src/components/verse-action-popover.tsx @@ -450,8 +450,8 @@ export const VerseActionPopover: FC = ({ const root = getOwnShadowRoot(content); retainOutsideFocusRef.current = false; const activeElement = root - ? (root.activeElement ?? - readerFocusRestoreTarget ?? + ? (readerFocusRestoreTarget ?? + root.activeElement ?? getShadowFocusRestoreTarget?.()) : documentFocusRestoreTargetRef.current; focusRestoreTargetRef.current = isElementFromOwnerDocument( From 23389a83f1a8676b423179caa4b863e95ca90172 Mon Sep 17 00:00:00 2001 From: Austin Date: Tue, 6 Oct 2026 08:52:01 -0500 Subject: [PATCH 11/16] test(ui): await reader search destination (YPE-5951) --- .../ui/src/components/bible-reader-search.stories.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/bible-reader-search.stories.tsx b/packages/ui/src/components/bible-reader-search.stories.tsx index c83087db..5e846caf 100644 --- a/packages/ui/src/components/bible-reader-search.stories.tsx +++ b/packages/ui/src/components/bible-reader-search.stories.tsx @@ -271,8 +271,12 @@ export const SearchAndReturn: Story = { ); await userEvent.click(result); await waitFor(() => expect(search.queryByRole('dialog')).not.toBeInTheDocument()); - const destinationVerse = root.querySelector('.yv-v[v="51"]'); - await waitFor(() => expect(root.activeElement).toBe(destinationVerse)); + 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); @@ -282,7 +286,7 @@ export const SearchAndReturn: Story = { const outsideControl = context.canvasElement.querySelector( '[data-testid="outside-reader-control"]', ); - if (!destinationVerse || !outsideControl) throw new Error('focus regression controls missing'); + if (!outsideControl) throw new Error('outside focus control missing'); outsideControl.focus(); await userEvent.click(destinationVerse); const verseActions = await waitFor(() => { From 4166e913d0d92dda19d0e0efabf2ecce3a3d3a8d Mon Sep 17 00:00:00 2001 From: Austin Date: Tue, 6 Oct 2026 08:57:03 -0500 Subject: [PATCH 12/16] test(ui): fixture reader search passages (YPE-5951) --- .../bible-reader-search.stories.tsx | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/bible-reader-search.stories.tsx b/packages/ui/src/components/bible-reader-search.stories.tsx index 5e846caf..64426565 100644 --- a/packages/ui/src/components/bible-reader-search.stories.tsx +++ b/packages/ui/src/components/bible-reader-search.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, fireEvent, userEvent, waitFor, within } from 'storybook/test'; import { BibleReader } from './bible-reader'; -import { useContext, useState } from 'react'; +import { useContext, useState, type ReactNode } from 'react'; import { YouVersionContext, type BibleSearchPhase, @@ -194,6 +194,10 @@ const searchResult: BibleSearchResult = { 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({ @@ -216,7 +220,7 @@ function useSearchStoryFixture(): UseBibleSearchResult { }; } -function SearchJourneyToolbar() { +function SearchJourneyFixture({ children }: { children: ReactNode }) { const context = useContext(YouVersionContext); if (context === null) throw new Error('Search story requires YouVersionProvider'); return ( @@ -226,8 +230,8 @@ function SearchJourneyToolbar() { hookOverrides: { ...context.hookOverrides, useBibleSearch: useSearchStoryFixture, - usePassage: () => ({ - passage: mockPassages['JHN.1.51'], + usePassage: ({ usfm }) => ({ + passage: searchJourneyPassage(usfm), loading: false, error: null, refetch: () => undefined, @@ -235,7 +239,7 @@ function SearchJourneyToolbar() { }, }} > - + {children} ); } @@ -249,8 +253,10 @@ export const SearchAndReturn: Story = { Outside reader - - + + + +
), From ba5daaeeec72be7f985900f9efcc7ae4d1824391 Mon Sep 17 00:00:00 2001 From: Austin Date: Tue, 6 Oct 2026 09:11:05 -0500 Subject: [PATCH 13/16] fix(ui): discard transient focus targets (YPE-5951) --- .../bible-reader-search.stories.tsx | 22 +++++++++++++++ .../src/components/verse-action-popover.tsx | 28 +++++++++++++++---- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/bible-reader-search.stories.tsx b/packages/ui/src/components/bible-reader-search.stories.tsx index 64426565..8228a6d4 100644 --- a/packages/ui/src/components/bible-reader-search.stories.tsx +++ b/packages/ui/src/components/bible-reader-search.stories.tsx @@ -305,6 +305,28 @@ export const SearchAndReturn: Story = { 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(destinationVerse); + await waitFor(() => + 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); }, }; diff --git a/packages/ui/src/components/verse-action-popover.tsx b/packages/ui/src/components/verse-action-popover.tsx index e2910164..b92b64b9 100644 --- a/packages/ui/src/components/verse-action-popover.tsx +++ b/packages/ui/src/components/verse-action-popover.tsx @@ -105,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; @@ -449,10 +460,13 @@ export const VerseActionPopover: FC = ({ const root = getOwnShadowRoot(content); retainOutsideFocusRef.current = false; + const readerTarget = + isElementFromOwnerDocument(readerFocusRestoreTarget, content, 'HTMLElement') && + isDurableFocusRestoreTarget(readerFocusRestoreTarget, content.ownerDocument) + ? readerFocusRestoreTarget + : null; const activeElement = root - ? (readerFocusRestoreTarget ?? - root.activeElement ?? - getShadowFocusRestoreTarget?.()) + ? (readerTarget ?? root.activeElement ?? getShadowFocusRestoreTarget?.()) : documentFocusRestoreTargetRef.current; focusRestoreTargetRef.current = isElementFromOwnerDocument( activeElement, @@ -466,9 +480,11 @@ export const VerseActionPopover: FC = ({ onCloseAutoFocus={(event) => { const root = portal.container ? getOwnShadowRoot(portal.container) : null; const target = focusRestoreTargetRef.current; - const restoreTarget = - target?.isConnected && (!root || target.ownerDocument === root.ownerDocument) - ? target + 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(); From 8b78a7453ec3dabdae8563d1e7c1ab1734a7cb85 Mon Sep 17 00:00:00 2001 From: Austin Date: Tue, 6 Oct 2026 10:56:35 -0500 Subject: [PATCH 14/16] refactor(ui): clarify reader focus ownership (YPE-5951) --- packages/ui/src/components/bible-reader-search.tsx | 1 - packages/ui/src/components/bible-reader.tsx | 7 ++++++- packages/ui/src/components/verse-action-popover.tsx | 5 +++-- packages/ui/src/lib/verse-action-popover-focus.tsx | 12 +++++++----- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/components/bible-reader-search.tsx b/packages/ui/src/components/bible-reader-search.tsx index 780a213e..b5ab7c87 100644 --- a/packages/ui/src/components/bible-reader-search.tsx +++ b/packages/ui/src/components/bible-reader-search.tsx @@ -214,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.tsx b/packages/ui/src/components/bible-reader.tsx index 83236f0f..4d0a5f8b 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -878,6 +878,11 @@ function Content() { const [popoverOpen, setPopoverOpen] = useState(false); const [anchorElement, setAnchorElement] = useState(null); const verseFocusRestoreTargetRef = useRef(null); + const takeVerseFocusRestoreTarget = React.useCallback(() => { + const target = verseFocusRestoreTargetRef.current; + verseFocusRestoreTargetRef.current = null; + return target; + }, []); const lastSelectionRef = useRef([]); const { @@ -1241,7 +1246,7 @@ function Content() { all — two action surfaces would stack. Selection, painting, and every payload above are untouched. */} {verseActions !== 'none' && ( - + 0} onOpenChange={handlePopoverOpenChange} diff --git a/packages/ui/src/components/verse-action-popover.tsx b/packages/ui/src/components/verse-action-popover.tsx index b92b64b9..6600138c 100644 --- a/packages/ui/src/components/verse-action-popover.tsx +++ b/packages/ui/src/components/verse-action-popover.tsx @@ -26,7 +26,7 @@ import { import { isDarkHighlightHex } from './verse'; import { useInterfaceDirection } from '@/lib/direction'; import { YvComponentStyles } from '@/lib/yv-styles-components'; -import { useVerseActionPopoverFocusRestoreTarget } from '@/lib/verse-action-popover-focus'; +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'; @@ -230,7 +230,7 @@ export const VerseActionPopover: FC = ({ const direction = useInterfaceDirection(); const portal = useShadowPortalState({ open, onOpenChange }); const getShadowFocusRestoreTarget = useShadowFocusRestoreTarget(); - const readerFocusRestoreTarget = useVerseActionPopoverFocusRestoreTarget(); + 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 @@ -460,6 +460,7 @@ 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) diff --git a/packages/ui/src/lib/verse-action-popover-focus.tsx b/packages/ui/src/lib/verse-action-popover-focus.tsx index b3337a31..643802dc 100644 --- a/packages/ui/src/lib/verse-action-popover-focus.tsx +++ b/packages/ui/src/lib/verse-action-popover-focus.tsx @@ -2,22 +2,24 @@ import { createContext, useContext, type ReactElement, type ReactNode } from 'react'; -const VerseActionPopoverFocusRestoreContext = createContext(null); +type TakeFocusRestoreTarget = () => Element | null; + +const VerseActionPopoverFocusRestoreContext = createContext(null); export function VerseActionPopoverFocusRestoreProvider({ - target, + takeTarget, children, }: { - target: Element | null; + takeTarget: TakeFocusRestoreTarget; children: ReactNode; }): ReactElement { return ( - + {children} ); } -export function useVerseActionPopoverFocusRestoreTarget(): Element | null { +export function useTakeVerseActionPopoverFocusRestoreTarget(): TakeFocusRestoreTarget | null { return useContext(VerseActionPopoverFocusRestoreContext); } From 18f3de72019e9a036670120d4c6cb89c4543b741 Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 7 Oct 2026 08:47:22 -0500 Subject: [PATCH 15/16] style(ui): align reader hook imports (YPE-5951) --- packages/ui/src/components/bible-reader.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index 4d0a5f8b..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, @@ -878,7 +879,7 @@ function Content() { const [popoverOpen, setPopoverOpen] = useState(false); const [anchorElement, setAnchorElement] = useState(null); const verseFocusRestoreTargetRef = useRef(null); - const takeVerseFocusRestoreTarget = React.useCallback(() => { + const takeVerseFocusRestoreTarget = useCallback(() => { const target = verseFocusRestoreTargetRef.current; verseFocusRestoreTargetRef.current = null; return target; From 740875bf89bf84cb19044c6ce746700bd3f27243 Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 7 Oct 2026 09:20:46 -0500 Subject: [PATCH 16/16] docs: align reader isolation evidence (YPE-5951) --- docs/shadow-dom-consumer-compatibility.md | 16 +++++++++++----- docs/shadow-dom-isolation-plan.md | 8 ++++---- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/shadow-dom-consumer-compatibility.md b/docs/shadow-dom-consumer-compatibility.md index 5e356b2c..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 diff --git a/docs/shadow-dom-isolation-plan.md b/docs/shadow-dom-isolation-plan.md index 587b3279..d6024d54 100644 --- a/docs/shadow-dom-isolation-plan.md +++ b/docs/shadow-dom-isolation-plan.md @@ -187,9 +187,9 @@ must land before YPE-5952 releases the coordinated major version. 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 roots 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. +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` @@ -201,7 +201,7 @@ 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 host +`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