diff --git a/.changeset/salty-lizards-dance.md b/.changeset/salty-lizards-dance.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/salty-lizards-dance.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/docs/adr/0007-prototype-shadow-dom-style-isolation.md b/docs/adr/0007-prototype-shadow-dom-style-isolation.md index 092d3312..b6db7b45 100644 --- a/docs/adr/0007-prototype-shadow-dom-style-isolation.md +++ b/docs/adr/0007-prototype-shadow-dom-style-isolation.md @@ -6,9 +6,11 @@ YPE-5356 accepts the architecture for a coordinated major-version rollout across the compatible public UI boundary defined in the [production rollout policy](../shadow-dom-rollout-policy.md). Implementation is split into dependency-ordered component groups, but the package must not publish -a partial boundary. This ADR continues to describe current runtime behavior -until those groups land: only `YouVersionAuthButton` creates an automatic shadow -root. +a partial boundary. In the current stable package, only `YouVersionAuthButton` +creates an automatic shadow root. On the Shadow DOM integration branch, +`BibleChapterPicker.Root` and `BibleVersionPicker.Root` also create automatic +boundaries while the remaining rollout groups and coordinated release are +pending. The automatic boundary belongs to the SDK-owned top-level component instance. Compound members and SDK components composed inside another isolated SDK @@ -178,11 +180,12 @@ Radix's development-only relationship checks can also emit warnings for valid IDs inside a shadow root because those checks query the document rather than the root. -Only `YouVersionAuthButton` is automatically isolated by this prototype. -`BibleVersionPicker` and other public exports do not gain automatic isolation -from the opt-in validation work. The internal `SignInDialog` is validated only -through an opt-in story. Wider automatic isolation requires completing the -linked implementation groups and coordinated major-release gate. +In the current stable package, only `YouVersionAuthButton` is automatically +isolated. On the Shadow DOM integration branch, `BibleChapterPicker.Root` and +`BibleVersionPicker.Root` also use automatic boundaries. The internal +`SignInDialog` remains validated only through an opt-in story. Wider automatic +isolation still requires the linked implementation groups and the coordinated +major-release gate. The detailed experimental evidence remains in the [Shadow DOM isolation validation plan](../shadow-dom-isolation-plan.md). The diff --git a/docs/shadow-dom-consumer-compatibility.md b/docs/shadow-dom-consumer-compatibility.md index 3a5c943e..b1b45afb 100644 --- a/docs/shadow-dom-consumer-compatibility.md +++ b/docs/shadow-dom-consumer-compatibility.md @@ -4,8 +4,10 @@ This contract records cross-browser evidence for consumer-facing behavior at the SDK's Shadow DOM boundary. YPE-5356 incorporates it into the -[production rollout policy](shadow-dom-rollout-policy.md); this contract alone -does not enable automatic isolation beyond `YouVersionAuthButton`. +[production rollout policy](shadow-dom-rollout-policy.md). On the Shadow DOM +integration branch, `YouVersionAuthButton`, `BibleChapterPicker.Root`, and +`BibleVersionPicker.Root` create automatic boundaries; the coordinated stable +release is still pending. The executable evidence lives in `consumer-compatibility.shadow-isolation.stories.tsx`. The existing @@ -19,8 +21,9 @@ scope. public event and forwarded-ref props. - `Textarea`, rendered through the internal opt-in `ShadowRootHost`, isolates a native form control without adding a production behavior or public wrapper. -- `BibleVersionPicker`, also rendered through the opt-in host, exercises a - composed public module with shadow-local floating content. +- `BibleChapterPicker.Root` and `BibleVersionPicker.Root` exercise automatic + compound-component boundaries with shadow-local floating content. Their + trigger, content, and language members reuse the owning root. These modules validate the shared boundary and specific public interfaces they exercise. They do not establish compatibility for every SDK component. @@ -40,7 +43,8 @@ exercise. They do not establish compatibility for every SDK component. | An automatically isolated component is nested inside another open SDK shadow root | Supported for basic rendering, traversal, and composed events | `NestedRootsRequireTraversalAndRetargetAtEveryBoundary` verifies recursive root traversal and target retargeting to the inner host in the outer scope and to the outer host in the document scope. Consumers must traverse every root explicitly. | | Nested overlays inside shadow roots | Supported in current browser evidence | YPE-5355 verifies nested dialog and popover stacking, focus, inertness, dismissal, and restoration through the shared shadow-local portal infrastructure. Repeat component-specific validation during rollout. | | 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 ID relationships inside `BibleVersionPicker` | Supported in current browser evidence | `TopLayerEscapesClippingAndPreservesSemantics` verifies that the trigger and controlled panel remain in one root and Chromium, Firefox, and Playwright WebKit resolve their `aria-controls` relationship. This does not make cross-scope ID references supported. | +| 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. | ## Consumer risks @@ -55,7 +59,7 @@ an automation and styling boundary rather than a security boundary. Selectors that depend on internal markup remain fragile even when they traverse the root. The focused Shadow DOM suite runs in Chromium, Firefox, and Playwright WebKit. -All 22 current stories returned assertion-level success in local Safari 26.6.2 +The 22-story pre-picker-rollout baseline returned assertion-level success in local Safari 26.6.2 through SafariDriver when each ran in a fresh browser session. A single long-lived SafariDriver session stalled on the sign-in dialog and verse action popover stories after 20 successes, so isolated sessions are required for this @@ -64,14 +68,11 @@ assistive technologies remain unverified. Reflected ARIA element properties demonstrate DOM relationship resolution, not announcements or other assistive-technology behavior. -## Follow-up work outside this ticket +## Follow-up work -No production defect is fixed by this validation ticket. If a selected rollout -component must participate in an outer native form or consume external labeling -relationships, create a component-specific implementation ticket for an -explicit public contract rather than relying on cross-scope browser behavior. -The current ticket's actual-Safari smoke is recorded above. Recurring Safari and -deferred assistive-technology validation, consumer-facing release documentation, -and production implementation are assigned by the -[production rollout policy](shadow-dom-rollout-policy.md). No runtime behavior -or Jira issue is created by this compatibility document. +If a rollout component must participate in an outer native form or consume +external labeling relationships, it needs an explicit public contract rather +than cross-scope browser behavior. Recurring Safari, deferred assistive- +technology validation, release documentation, and the coordinated stable +release remain assigned by the +[production rollout policy](shadow-dom-rollout-policy.md). diff --git a/docs/shadow-dom-isolation-plan.md b/docs/shadow-dom-isolation-plan.md index a1452d46..7eb6f54f 100644 --- a/docs/shadow-dom-isolation-plan.md +++ b/docs/shadow-dom-isolation-plan.md @@ -18,15 +18,18 @@ This is a working plan, not approval for package-wide rollout. ## Current scope -- `YouVersionAuthButton` is the only component automatically isolated by the - current prototype. -- `BibleVersionPicker` validates shadow-local inline and native top-layer - floating content through opt-in stories. +- In the current stable package, `YouVersionAuthButton` is the only component + with automatic isolation. On the Shadow DOM integration branch, + `BibleChapterPicker.Root` and `BibleVersionPicker.Root` also create automatic + boundaries. +- The picker roots validate shadow-local native top-layer floating content + through their public runtime boundaries. A historical inline negative + control demonstrated clipping beyond a constrained ancestor before that + story was removed after strategy selection. - The shared Dialog and Popover primitives support opt-in shadow-local portals. - `VerseActionPopover` uses the shared portal-state infrastructure while retaining its specialized direct Radix composition. -- `BibleVersionPicker` and other public exports do not automatically create - Shadow DOM boundaries. +- Other public exports do not yet automatically create Shadow DOM boundaries. - The internal `SignInDialog` is validated only through an opt-in `ShadowRootHost` story. - Nested and concurrent overlays within and across component shadow roots were @@ -61,8 +64,8 @@ functions; assistive-technology checks remain open. | Host CSS isolation | Hostile-CSS demos and focused browser coverage exercise element selectors, direction inheritance, vertical writing and typography resets, hostile custom properties, universal `!important` rules, host attacks, and generated pseudo-content. | Validated in Chromium, Firefox, Playwright WebKit, and local Safari 26.6.2 | Repeat against each component selected for rollout. | | SSR and hydration | Focused React coverage verifies reuse of the exact empty server host, matching hydration without recoverable errors or duplicate content, and a null forwarded ref before the passive-effect mount. | Validated for the client-only prototype | Decide per rollout component whether a possibly empty first paint, layout shift, and no-JavaScript absence are acceptable. | | Component behavior | Auth button interaction works through the React portal; Strict Mode does not attach the root twice. | Validated for the prototype | Audit component-specific refs, events, and consumer integrations during rollout. | -| Owner-document handling | Focused coverage mounts into a same-origin iframe and verifies document-compatible stylesheet construction. | Validated in Chromium, Firefox, Playwright WebKit, and local Safari 26.6.2 | Verify stylesheet failure recovery. | -| Inline floating content | The picker negative control preserves tree-scope relationships but demonstrates clipping beyond a constrained ancestor. | Validated as a negative control | None; clipping is why inline placement is not the selected escaping strategy. | +| Owner-document handling | Focused coverage mounts into a same-origin iframe and verifies document-compatible stylesheet construction. YPE-5947 also covers construction, replacement, and adoption failure recovery. | Validated in Chromium, Firefox, Playwright WebKit, and local Safari 26.6.2 | None. | +| Inline floating content | A historical picker negative control preserved tree-scope relationships but demonstrated clipping beyond a constrained ancestor. | Validated historically; the story was removed after strategy selection. | None; clipping is why inline placement is not the selected escaping strategy. | | Native top-layer floating content | Picker stories verify clipping escape, hit testing, collision handling, hostile-CSS isolation, and resolved `aria-controls` relationships. | Validated in Chromium, Firefox, Playwright WebKit, and local Safari 26.6.2 | Verify assistive-technology behavior and repeat actual-Safari checks for significant platform changes. | | Portal lifecycle | Unit and browser coverage exercise lazy creation, exit-animation retention, cleanup, immediate reopen behavior, and the direct-Radix `VerseActionPopover` consumer. | Validated for shared primitives and the known bypass | Repeat the consumer audit when adding another direct overlay primitive. | | Dialog relationships | Browser coverage resolves title and description relationships inside the component tree. | Validated in Chromium, Firefox, Playwright WebKit, and an isolated local Safari 26.6.2 run | Verify announcements with real assistive technology. | @@ -131,7 +134,7 @@ generated `--tw-*` names, and the local `--spacing` compatibility alias. | Name or namespace | Classification and ownership | | --- | --- | -| `--yv-*` | SDK-owned properties. The README's documented overrides are supported consumer inputs for light-DOM components under `[data-yv-sdk]`. They are not a public document-level override API for the automatically isolated `YouVersionAuthButton`. | +| `--yv-*` | SDK-owned properties. The README's documented overrides are supported consumer inputs for light-DOM components under `[data-yv-sdk]`. They are not a public document-level override API for automatically isolated components. | | `--tw-*` | Tailwind and `tw-animate-css` implementation state that is declared or initialized in the compiled stylesheet. It is not a supported consumer input. | | `--spacing` | SDK-owned local compatibility alias for `--yv-spacing`, used by `tw-animate-css`. The fixed Tailwind spacing scale is inlined into generated utilities. | | Authored `--font-*`, `--color-*`, and `--radius-*` theme aliases | Compile-time Tailwind inputs that produce utilities backed by `--yv-*` values. They are not runtime consumer inputs. | @@ -182,8 +185,8 @@ separately in YPE-5749. size; review that accepted sizing input for each rollout component. - Repeat the documented event, ref, nested-root, and shadow-aware automation checks for every public component selected for rollout. -- Verify stylesheet construction and adoption failure recovery beyond the - current feature fallback. +- Preserve YPE-5947's stylesheet construction, replacement, and adoption + failure-recovery coverage. - Preserve YPE-5437's realistic-usage fixture as the shared-host regression check. Its one-machine mount comparison is diagnostic, so selected rollout components still need user-visible performance review in their intended @@ -207,11 +210,11 @@ separately in YPE-5749. same-page JavaScript from inspecting or mutating the root. - The focused Shadow DOM browser suite runs in Chromium, Firefox, and Playwright WebKit. Playwright WebKit is not a substitute for testing actual Safari. All - 22 current stories returned explicit success events in local Safari 26.6.2 - when each ran in a fresh SafariDriver session. A single long-lived session - returned success for 20/22 and left the sign-in and verse-action play - functions pending. The cause of that session-dependent stall is unresolved; - repeat actual-Safari validation in isolated sessions. + 22-story pre-picker-rollout baseline returned explicit success events in + local Safari 26.6.2 when each story ran in a fresh SafariDriver session. A + single long-lived session returned success for 20/22 and left the sign-in and + verse-action play functions pending. The cause of that session-dependent + stall is unresolved; repeat actual-Safari validation in isolated sessions. - Browser DOM relationship reflection is not a substitute for VoiceOver, NVDA, or other real assistive-technology verification. diff --git a/docs/shadow-dom-rollout-policy.md b/docs/shadow-dom-rollout-policy.md index 807aef1a..6bcbf16a 100644 --- a/docs/shadow-dom-rollout-policy.md +++ b/docs/shadow-dom-rollout-policy.md @@ -5,9 +5,12 @@ YPE-5356 approves a coordinated package-wide rollout plan for compatible public UI components. It does not claim that the rollout has shipped. Until every included implementation group and the release gate below are complete, the -runtime behavior remains the prototype recorded in -[ADR 0007](adr/0007-prototype-shadow-dom-style-isolation.md): only -`YouVersionAuthButton` creates an automatic shadow boundary. +runtime behavior on the Shadow DOM integration branch remains an in-progress +implementation of the prototype recorded in +[ADR 0007](adr/0007-prototype-shadow-dom-style-isolation.md): +`YouVersionAuthButton`, `BibleChapterPicker.Root`, and +`BibleVersionPicker.Root` create automatic shadow boundaries. The picker member +exports reuse their owning root rather than creating independent boundaries. The rollout is coordinated at release time, not implemented in one change. Focused component or component-group tickets may land independently on the @@ -47,6 +50,7 @@ component rollout targets. | `BibleVersionPicker.Root`, `.Trigger`, `.Content` | Included as one compound component | `Root` only | Promote the validated opt-in host to the public root and audit custom trigger styling, storage, focus, and native top-layer behavior. | | `BibleLanguagePickerContent`, `BibleVersionPickerLanguageTrigger` | Included transitively | No independent boundary | Both require `BibleVersionPicker.Root` context and stay inside that root. Direct use outside the root is already unsupported. | | `BibleReader.Root`, `.Content`, `.Toolbar` | Included as one compound component | `Root` only | Keep reader content, toolbar, pickers, settings, verse actions, and dialogs in one boundary. Audit consumer children, scrolling, selection, overlays, focus, refs, and first paint. | +| `BibleReaderSearch` | Included transitively | No independent boundary | It requires `BibleReader.Root` context and stays inside that root whether rendered by the toolbar or directly by a consumer. Preserve its shadow-local popover, controlled and host-owned modes, navigation, dismissal, and focus behavior. | | `BibleThemeSettingsContent` | Included | Its standalone mount, or the owning reader boundary | Preserve its Expo DOM callback contract and avoid a nested boundary when rendered by `BibleReader`. | | `BibleTextView` | Included | Its standalone mount, or the owning card/reader boundary | Preserve scripture rendering, footnote portals, selection callbacks, and reader stylesheet behavior without nesting roots inside composed SDK components. | | `FootnoteContent` | Included | Its standalone mount, or the owning scripture boundary | Treat it as a leaf when used alone and reuse the enclosing `BibleTextView` boundary otherwise. | @@ -122,15 +126,19 @@ exported function. Each ticket links to YPE-5356 and this policy. only boundaries for their compound exports. - Validate custom trigger children, context, storage, search inputs, focus, collision handling, and shadow-local top-layer behavior. + - Retain document-root `rem` scaling as the accepted sizing input; do not add + a picker-specific root-font reset. 4. **YPE-5950: Scripture presentation** - Roll out standalone `BibleTextView`, `VerseOfTheDay`, and `BibleCard`. - Validate reader styles, scripture direction, footnotes, highlights, sharing, loading/error states, picker composition, sizing, and first paint. 5. **YPE-5951: Bible reader** - Roll out `BibleReader.Root` as the boundary for reader content, toolbar, - pickers, settings, verse actions, permission dialogs, and sign-in dialogs. - - Validate selection, scrolling, native-host callback modes, nested overlay - order, focus restoration, and user-visible performance in intended layouts. + `BibleReaderSearch`, pickers, settings, verse actions, permission dialogs, + and sign-in dialogs. + - Validate selection, search navigation and dismissal, scrolling, native-host + callback modes, nested overlay order, focus restoration, and user-visible + performance in intended layouts. 6. **YPE-5952: Coordinated release** - Land all included groups, complete the package and component gates, update consumer documentation, and publish the behavior as one major release. @@ -139,7 +147,8 @@ YPE-5947 adds no public Shadow DOM configuration and does not expand the current automatic boundary beyond `YouVersionAuthButton`. Its independently releasable runtime effect is a patch-level resilience fix for stylesheet installation; the additional automatic component boundaries remain part of the coordinated major -release. +release. The picker boundaries described above are implemented on the Shadow DOM +integration branch but are not yet a stable-package release contract. Excluded components are not hidden work in these groups. `Textarea` needs a separately approved form contract, and standalone `VerseActionPopover` needs an diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md index 6a3383bb..d2d092ee 100644 --- a/packages/ui/AGENTS.md +++ b/packages/ui/AGENTS.md @@ -60,6 +60,10 @@ Storybook stories are the live reference for every component's props and states. `examples/vite-react` at the repo root shows integration in a real app. Both are type-checked; prefer them over any prose description of a component's API. +Shadow DOM boundaries or overlays: read +`../../docs/shadow-dom-rollout-policy.md`; validation evidence lives in +`../../docs/shadow-dom-isolation-plan.md`. + ## TESTING Follow `docs/testing.md`. This package’s flavors: diff --git a/packages/ui/src/components/bible-card.stories.tsx b/packages/ui/src/components/bible-card.stories.tsx index 53ecd9d1..443626ec 100644 --- a/packages/ui/src/components/bible-card.stories.tsx +++ b/packages/ui/src/components/bible-card.stories.tsx @@ -4,6 +4,30 @@ import { http, HttpResponse } from 'msw'; import { BibleCard } from './bible-card'; import { globalHandlers } from '../test/mocks/handlers'; +async function getVersionPickerQueries(container: ParentNode, triggerName: RegExp) { + return waitFor(() => { + for (const host of container.querySelectorAll('[data-yv-shadow-host]')) { + if (!host.shadowRoot) continue; + const content = host.shadowRoot.querySelector( + '[data-yv-shadow-content-wrapper]', + ); + if (!content) continue; + const picker = within(content); + const trigger = picker.queryByRole('button', { name: triggerName }); + if (trigger) return { root: host.shadowRoot, picker, trigger }; + } + throw new globalThis.Error('version picker shadow root not rendered'); + }); +} + +async function getVersionPickerOverlay(root: ShadowRoot) { + return waitFor(() => { + const overlay = root.querySelector('[data-yv-shadow-local-overlay]'); + if (!overlay) throw new globalThis.Error('version picker overlay not rendered'); + return within(overlay); + }); +} + const meta = { title: 'Components/BibleCard', component: BibleCard, @@ -75,9 +99,10 @@ export const RtlInterfaceWithLtrScripture: Story = { tags: ['integration'], play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const versionPicker = await canvas.findByRole('button', { - name: /تغيير إصدار الكتاب المقدس/i, - }); + const { trigger: versionPicker } = await getVersionPickerQueries( + canvasElement, + /تغيير إصدار الكتاب المقدس/i, + ); await canvas.findByText(/at that time mary got ready/i); const card = canvasElement.querySelector('section[data-yv-sdk]'); const reference = canvasElement.querySelector('h2'); @@ -160,9 +185,11 @@ export const WithVersionPicker: Story = { const canvas = within(canvasElement); // Wait for initial content to load - const versionPickerButton = await canvas.findByRole('button', { - name: /change bible version/i, - }); + const { + root, + picker, + trigger: versionPickerButton, + } = await getVersionPickerQueries(canvasElement, /change bible version/i); await waitFor(async () => { await expect(versionPickerButton).toHaveTextContent(/NIV/i); @@ -171,13 +198,13 @@ export const WithVersionPicker: Story = { // Open version picker dialog await userEvent.click(versionPickerButton); + const overlay = await getVersionPickerOverlay(root); - // Use screen for portal elements (popover renders outside canvas) - await expect(await screen.findByRole('dialog')).toBeInTheDocument(); + await expect(await overlay.findByRole('dialog')).toBeInTheDocument(); // Wait for versions to actually load (not just the container) await waitFor(async () => { - const versionList = within(await screen.findByRole('dialog')).getByTestId('version-list'); + const versionList = overlay.getByTestId('version-list'); // Search for New International Version to exist to show data came back from API await within(versionList).findByText(/new international version 2011/i); const items = await within(versionList).findAllByRole('listitem'); @@ -185,27 +212,27 @@ export const WithVersionPicker: Story = { }); // Search for Amplified Bible - const searchInput = within(await screen.findByRole('dialog')).getByRole('textbox', { + const searchInput = overlay.getByRole('textbox', { name: /search bible versions/i, }); await userEvent.type(searchInput, 'amplified bible'); await waitFor(async () => { - const versionList = within(await screen.findByRole('dialog')).getByTestId('version-list'); + const versionList = overlay.getByTestId('version-list'); const versionItems = within(versionList).getAllByRole('listitem'); await expect(versionItems).toHaveLength(1); await expect(versionItems[0]).toHaveTextContent(/amplified bible/i); }); // Select Amplified Bible version - const versionListItem = within(await screen.findByRole('dialog')).getByRole('listitem', { + const versionListItem = overlay.getByRole('listitem', { name: /amplified bible/i, }); await userEvent.click(versionListItem); // Verify version changed to AMP await waitFor(async () => { - await expect(screen.getByRole('button', { name: /change bible version/i })).toHaveTextContent( + await expect(picker.getByRole('button', { name: /change bible version/i })).toHaveTextContent( 'AMP', ); }); @@ -277,9 +304,10 @@ export const Error: Story = { await expect(alerts[0]).toHaveAttribute('aria-live', 'polite'); // The picker is the in-card recovery path: a 404 is fixed by switching versions. - const versionPickerButton = await canvas.findByRole('button', { - name: /change bible version/i, - }); + const { root, trigger: versionPickerButton } = await getVersionPickerQueries( + canvasElement, + /change bible version/i, + ); await waitFor(async () => { await expect(versionPickerButton).toBeEnabled(); @@ -289,19 +317,19 @@ export const Error: Story = { // Walk the recovery path: switch to a version whose passage resolves. await userEvent.click(versionPickerButton); - const dialog = await screen.findByRole('dialog'); - const searchInput = within(dialog).getByRole('textbox', { name: /search bible versions/i }); + const overlay = await getVersionPickerOverlay(root); + const searchInput = overlay.getByRole('textbox', { name: /search bible versions/i }); await userEvent.type(searchInput, 'amplified bible'); await waitFor(async () => { - const versionList = within(dialog).getByTestId('version-list'); + const versionList = overlay.getByTestId('version-list'); const versionItems = within(versionList).getAllByRole('listitem'); await expect(versionItems).toHaveLength(1); await expect(versionItems[0]).toHaveTextContent(/amplified bible/i); }); - await userEvent.click(within(dialog).getByRole('listitem', { name: /amplified bible/i })); + await userEvent.click(overlay.getByRole('listitem', { name: /amplified bible/i })); // The error clears: no alert, and the passage replaces the "Error" heading. await waitFor(async () => { diff --git a/packages/ui/src/components/bible-chapter-picker.shadow-isolation.stories.tsx b/packages/ui/src/components/bible-chapter-picker.shadow-isolation.stories.tsx new file mode 100644 index 00000000..69ebb117 --- /dev/null +++ b/packages/ui/src/components/bible-chapter-picker.shadow-isolation.stories.tsx @@ -0,0 +1,196 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { BibleBook } from '@youversion/platform-core'; +import { YouVersionContext, type HookOverrides } from '@youversion/platform-react-hooks'; +import { useContext, useState, type ReactNode } from 'react'; +import { expect, userEvent, within } from 'storybook/test'; +import { waitFor, waitForElement, waitForShadowRoot } from '../test/storybook-dom'; +import { BibleChapterPicker } from './bible-chapter-picker'; + +const FIXTURE_BOOKS: BibleBook[] = [ + { + id: 'GEN', + title: 'Genesis', + full_title: 'Genesis', + abbreviation: 'Gen', + canon: 'old_testament', + chapters: Array.from({ length: 11 }, (_, index) => ({ + id: String(index + 1), + passage_id: `GEN.${index + 1}`, + title: String(index + 1), + })), + }, + { + id: 'EXO', + title: 'Exodus', + full_title: 'Exodus', + abbreviation: 'Exod', + canon: 'old_testament', + chapters: [{ id: '1', passage_id: 'EXO.1', title: '1' }], + }, + { + id: 'MAT', + title: 'Matthew', + full_title: 'Matthew', + abbreviation: 'Matt', + canon: 'new_testament', + chapters: Array.from({ length: 5 }, (_, index) => ({ + id: String(index + 1), + passage_id: `MAT.${index + 1}`, + title: String(index + 1), + })), + }, +]; + +const HOOK_OVERRIDES = { + useBooks: () => ({ + books: { data: FIXTURE_BOOKS, next_page_token: null }, + loading: false, + error: null, + refetch: () => undefined, + }), +} satisfies HookOverrides; + +function FixtureProviders({ children }: { children: ReactNode }): ReactNode { + const parentContext = useContext(YouVersionContext); + const value = parentContext + ? { ...parentContext, hookOverrides: HOOK_OVERRIDES } + : { appKey: 'test', hookOverrides: HOOK_OVERRIDES }; + + return {children}; +} + +function AutomaticBibleChapterPicker(): React.ReactNode { + const [book, setBook] = useState('MAT'); + const [chapter, setChapter] = useState('5'); + + return ( + + + {({ currentBook, chapterLabel }) => ( + + )} + + + ); +} + +const meta = { + title: 'Components/BibleChapterPicker/Shadow isolation', + component: AutomaticBibleChapterPicker, + tags: ['integration', 'shadow-dom'], + parameters: { layout: 'centered' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const PublicRootJourney: Story = { + render: () => ( + +
+ +
+
+ ), + play: async ({ canvasElement }) => { + const clippingContainer = await waitForElement( + canvasElement, + '[data-testid="clipping-container"]', + 'clipping container not rendered', + ); + const root = await waitForShadowRoot(clippingContainer); + await expect(canvasElement.querySelectorAll('[data-yv-shadow-host]')).toHaveLength(1); + await expect(root.querySelectorAll('[data-yv-shadow-host]')).toHaveLength(0); + + const trigger = await waitForElement( + root, + 'button.consumer-chapter-trigger', + 'chapter picker trigger not rendered', + ); + await expect(trigger).toHaveTextContent(/matthew 5/i); + await expect(trigger).toHaveAttribute('data-consumer-attribute', 'preserved'); + await expect(trigger).toHaveStyle({ + backgroundColor: 'rgb(12, 34, 56)', + color: 'rgb(255, 255, 255)', + }); + const triggerStyle = getComputedStyle(trigger); + const embeddedBorderRadius = triggerStyle.borderRadius; + await expect(triggerStyle.paddingBlockStart).toBe('8px'); + await expect(triggerStyle.paddingInlineStart).toBe('16px'); + await expect(embeddedBorderRadius).not.toBe('0px'); + + const hostileStyle = canvasElement.ownerDocument.createElement('style'); + hostileStyle.textContent = ` + .consumer-chapter-trigger { + background-color: rgb(185, 28, 28) !important; + border-radius: 0 !important; + color: rgb(255, 255, 0) !important; + } + `; + try { + canvasElement.ownerDocument.head.append(hostileStyle); + await expect(getComputedStyle(trigger).backgroundColor).toBe('rgb(12, 34, 56)'); + await expect(getComputedStyle(trigger).borderRadius).toBe(embeddedBorderRadius); + await expect(getComputedStyle(trigger).color).toBe('rgb(255, 255, 255)'); + } finally { + hostileStyle.remove(); + } + + await userEvent.click(trigger); + + const topLayer = await waitForElement( + root, + '[data-yv-shadow-local-overlay]', + 'local top-layer container not created', + ); + const panel = await waitForElement( + topLayer, + '[data-slot="popover-content"]', + 'chapter picker panel not rendered', + ); + await expect(trigger.getRootNode()).toBe(root); + await expect(panel.getRootNode()).toBe(root); + await expect(topLayer.matches(':popover-open')).toBe(true); + await expect(trigger.getAttribute('aria-controls')).toBe(panel.id); + // SAFETY: The reflected-ARIA property is still a draft; verify browser support before use. + const reflectedControls = ( + trigger as HTMLElement & { ariaControlsElements?: readonly Element[] } + ).ariaControlsElements; + if (!reflectedControls) throw new Error('browser did not expose ariaControlsElements'); + await expect(reflectedControls).toEqual([panel]); + + const panelQueries = within(panel); + const genesisBook = panelQueries.getByRole('button', { name: /genesis/i }); + const search = panelQueries.getByPlaceholderText(/search/i); + await userEvent.type(search, 'g'); + await waitFor(async () => { + await expect(search).toHaveValue('g'); + await expect(genesisBook).toBeVisible(); + await expect(panelQueries.queryByRole('button', { name: /exodus/i })).toBeNull(); + }); + await userEvent.click(genesisBook); + await userEvent.click(await panelQueries.findByRole('button', { name: '11' })); + + await waitFor(async () => { + await expect(trigger).toHaveTextContent(/genesis 11/i); + await expect(topLayer.querySelector('[data-slot="popover-content"]')).toBeNull(); + await expect(root.activeElement).toBe(trigger); + }); + }, +}; diff --git a/packages/ui/src/components/bible-chapter-picker.stories.tsx b/packages/ui/src/components/bible-chapter-picker.stories.tsx index 45fbad7f..21f0a393 100644 --- a/packages/ui/src/components/bible-chapter-picker.stories.tsx +++ b/packages/ui/src/components/bible-chapter-picker.stories.tsx @@ -1,7 +1,27 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { BibleChapterPicker } from './bible-chapter-picker'; -import { within, waitFor, expect, userEvent } from 'storybook/test'; +import { fireEvent, within, waitFor, expect, userEvent } from 'storybook/test'; import { useState } from 'react'; +import { waitForElement, waitForShadowRoot } from '../test/storybook-dom'; + +async function getPickerQueries(container: ParentNode) { + const root = await waitForShadowRoot(container); + const content = await waitForElement( + root, + '[data-yv-shadow-content-wrapper]', + 'chapter picker content not rendered', + ); + return { root, picker: within(content) }; +} + +async function getOverlayQueries(root: ShadowRoot) { + const overlay = await waitForElement( + root, + '[data-yv-shadow-local-overlay]', + 'chapter picker overlay not rendered', + ); + return within(overlay); +} const meta = { title: 'Components/BibleChapterPicker', @@ -50,48 +70,49 @@ export const LightBackground: Story = { }, tags: ['integration'], play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const body = within(document.body); + const { root, picker } = await getPickerQueries(canvasElement); // Wait for the component to load - button should show book title once books are loaded - const popoverTrigger = await canvas.findByRole('button', { name: /matthew 5/i }); + const popoverTrigger = await picker.findByRole('button', { name: /matthew 5/i }); // Click the book selector to open dropdown await userEvent.click(popoverTrigger); + const overlay = await getOverlayQueries(root); // Expect the Genesis accordion trigger to be visible await waitFor(async () => { - const accordionButton = body.getByRole('button', { name: /Genesis/i }); + const accordionButton = overlay.getByRole('button', { name: /Genesis/i }); await expect(accordionButton).toBeVisible(); await expect(accordionButton).toHaveAttribute('data-slot', 'accordion-trigger'); }); // Test search functionality - const searchInput = body.getByPlaceholderText(/search/i); + const searchInput = overlay.getByPlaceholderText(/search/i); await userEvent.type(searchInput, 'exo'); // Verify search results show Exodus - await waitFor(() => expect(body.getByText('Exodus')).toBeInTheDocument()); + await waitFor(() => expect(overlay.getByText('Exodus')).toBeInTheDocument()); // Verify Genesis is filtered out - await expect(body.queryByText('Genesis')).not.toBeInTheDocument(); + await waitFor(() => expect(overlay.queryByText('Genesis')).not.toBeInTheDocument()); // Clear search and select Genesis - await userEvent.clear(searchInput); - await waitFor(() => expect(body.getByText('Genesis')).toBeInTheDocument()); - await userEvent.click(body.getByText('Genesis')); + const currentSearchInput = overlay.getByPlaceholderText(/search/i); + await fireEvent.input(currentSearchInput, { target: { value: '' } }); + await waitFor(() => expect(overlay.getByText('Genesis')).toBeInTheDocument()); + await userEvent.click(overlay.getByText('Genesis')); // Wait for chapter selector to become available - await waitFor(() => expect(body.getByText('11')).toBeInTheDocument()); + await waitFor(() => expect(overlay.getByText('11')).toBeInTheDocument()); // Click chapter selector to select chapter 11 - await userEvent.click(body.getByText('11')); + await userEvent.click(overlay.getByText('11')); // Assert that the button text changed to Genesis 11 await waitFor(() => - expect(canvas.getByRole('button', { name: /^genesis 11$/i })).toBeInTheDocument(), + expect(picker.getByRole('button', { name: /^genesis 11$/i })).toBeInTheDocument(), ); - await expect(canvas.queryByRole('button', { name: /^genesis 5$/i })).not.toBeInTheDocument(); + await expect(picker.queryByRole('button', { name: /^genesis 5$/i })).not.toBeInTheDocument(); }, }; @@ -105,10 +126,10 @@ export const DarkBackground: Story = { tags: ['integration'], // Using the play function get component in the open state for visual testing play: async ({ canvasElement }) => { - const canvas = within(canvasElement); + const { picker } = await getPickerQueries(canvasElement); // Wait for the component to load - const popoverTrigger = await canvas.findByRole('button', { name: /matthew 5/i }); + const popoverTrigger = await picker.findByRole('button', { name: /matthew 5/i }); await waitFor(() => expect(popoverTrigger).toBeInTheDocument()); // Click the book selector to open dropdown @@ -123,18 +144,18 @@ export const CustomStartingPoint: Story = { tags: ['integration'], // Using the play function get component in the open state for visual testing play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const body = within(document.body); + const { root, picker } = await getPickerQueries(canvasElement); // Wait for the component to load - button should show book title once books are loaded - const popoverTrigger = await canvas.findByRole('button', { name: /matthew 5/i }); + const popoverTrigger = await picker.findByRole('button', { name: /matthew 5/i }); // Click the book selector to open dropdown await userEvent.click(popoverTrigger); + const overlay = await getOverlayQueries(root); // Expect the selected book accordion trigger to be visible await waitFor(async () => { - const matchingButtons = body.queryAllByRole('button', { name: /Matthew/i }); + const matchingButtons = overlay.queryAllByRole('button', { name: /Matthew/i }); const accordionTrigger = matchingButtons.find( (button) => button.getAttribute('data-slot') === 'accordion-trigger', ); diff --git a/packages/ui/src/components/bible-chapter-picker.test.tsx b/packages/ui/src/components/bible-chapter-picker.test.tsx index 905ccd1d..0667bc8b 100644 --- a/packages/ui/src/components/bible-chapter-picker.test.tsx +++ b/packages/ui/src/components/bible-chapter-picker.test.tsx @@ -22,6 +22,7 @@ import type { HookOverrides } from '@youversion/platform-react-hooks'; import type { BibleBook } from '@youversion/platform-core'; import { HookOverrideProvider } from '@/test/hook-overrides'; import { InterfaceDirectionProvider } from '@/lib/direction'; +import { ReuseShadowBoundary } from '@/lib/shadow-isolation'; const mockBooks: BibleBook[] = [ { @@ -61,7 +62,11 @@ function defaultOverrides(): HookOverrides { } function renderWithOverrides(ui: ReactElement) { - return render({ui}); + return render( + + {ui} + , + ); } function findAccordionTrigger(name: RegExp): HTMLElement | undefined { diff --git a/packages/ui/src/components/bible-chapter-picker.tsx b/packages/ui/src/components/bible-chapter-picker.tsx index 2ebfca27..2ee69ef6 100644 --- a/packages/ui/src/components/bible-chapter-picker.tsx +++ b/packages/ui/src/components/bible-chapter-picker.tsx @@ -24,6 +24,7 @@ import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from './ui/accordion'; import { InputGroup, InputGroupInput, InputGroupAddon } from './ui/input-group'; import { useInterfaceDirection } from '@/lib/direction'; +import { ShadowIsolationBoundary } from '@/lib/shadow-isolation'; export interface BibleChapterPickerPressData { book: string; @@ -86,7 +87,7 @@ export type RootProps = { export type BibleChapterPickerRootProps = RootProps; -function Root({ +function RootImplementation({ book: controlledBook, defaultBook = '', onBookChange, @@ -220,6 +221,14 @@ function Root({ ); } +function Root(props: RootProps) { + return ( + + + + ); +} + /** * Props for the chapter picker trigger. Interface direction owns the rendered * trigger's `dir`; a consumer-supplied `dir` is intentionally ignored. @@ -436,7 +445,7 @@ function Content({ onRequestClose, onSelect }: BibleChapterPickerContentProps) { dir="auto" className="yv:text-base yv:leading-normal" value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} + onInput={(event) => setSearchQuery(event.currentTarget.value)} /> diff --git a/packages/ui/src/components/bible-picker-shadow-isolation.test.tsx b/packages/ui/src/components/bible-picker-shadow-isolation.test.tsx new file mode 100644 index 00000000..c675f245 --- /dev/null +++ b/packages/ui/src/components/bible-picker-shadow-isolation.test.tsx @@ -0,0 +1,142 @@ +/** + * @vitest-environment jsdom + */ +import { act, waitFor } from '@testing-library/react'; +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 { HookOverrideProvider } from '@/test/hook-overrides'; +import { installResizeObserverStub } from '@/test/dom-stubs'; +import { BibleChapterPicker, type BibleChapterPickerPressData } from './bible-chapter-picker'; +import { + BibleLanguagePickerContent, + BibleVersionPicker, + BibleVersionPickerLanguageTrigger, + type BibleVersionPickerPressData, +} from './bible-version-picker'; + +installResizeObserverStub(); + +const overrides: HookOverrides = { + useBooks: () => ({ + books: { data: [], next_page_token: null }, + loading: false, + error: null, + refetch: () => undefined, + }), + useVersions: () => ({ + versions: { data: [], next_page_token: null }, + loading: false, + error: null, + refetch: () => undefined, + }), + useVersion: () => ({ + version: null, + loading: false, + error: null, + refetch: () => undefined, + }), + useLanguages: () => ({ + languages: { data: [], next_page_token: null }, + loading: false, + error: null, + refetch: () => undefined, + }), + useLanguage: () => ({ + language: null, + loading: false, + error: null, + refetch: () => undefined, + }), + useFilteredVersions: () => [], + useOrganizations: () => ({ organizations: new Map() }), +}; + +function chapterPicker() { + const onPress = vi.fn<(data: BibleChapterPickerPressData) => void>(); + const element: ReactElement = ( + + + + + + + ); + return { element, expectedPress: { book: 'GEN', chapter: '1', versionId: 111 }, onPress }; +} + +function versionPicker() { + const onPress = vi.fn<(data: BibleVersionPickerPressData) => void>(); + const element: ReactElement = ( + + + + + + + + + ); + return { element, expectedPress: { languageId: 'en', versionId: 111 }, onPress }; +} + +describe('Bible picker public shadow boundaries', () => { + it.each([ + ['BibleChapterPicker.Root', chapterPicker], + ['BibleVersionPicker.Root', versionPicker], + ])('%s owns one empty SSR host and reuses it during hydration', async (_, picker) => { + const fixture = picker(); + const element = ( + {fixture.element} + ); + 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) throw new Error('picker shadow root not attached'); + return candidate; + }); + + expect(container.querySelectorAll('[data-yv-shadow-host]')).toHaveLength(1); + expect(host).toBe(serverHost); + expect(host.childNodes).toHaveLength(0); + expect(host.shadowRoot?.querySelectorAll('[data-yv-shadow-host]')).toHaveLength(0); + const trigger = host.shadowRoot?.querySelector( + '[data-testid="picker-trigger"]', + ); + expect(trigger).not.toBeNull(); + await act(async () => trigger?.click()); + expect(fixture.onPress).toHaveBeenCalledOnce(); + expect(fixture.onPress).toHaveBeenCalledWith(fixture.expectedPress); + expect(recoverableErrors).toEqual([]); + expect(consoleError).not.toHaveBeenCalled(); + } finally { + if (root) await act(async () => root?.unmount()); + consoleError.mockRestore(); + container.remove(); + } + }); +}); diff --git a/packages/ui/src/components/bible-reader-controlled.test.tsx b/packages/ui/src/components/bible-reader-controlled.test.tsx index a0187c3a..0f2123ec 100644 --- a/packages/ui/src/components/bible-reader-controlled.test.tsx +++ b/packages/ui/src/components/bible-reader-controlled.test.tsx @@ -29,6 +29,7 @@ import { import type { HighlightedVerses } from '@/lib/highlight-colors'; import { HIGHLIGHT_COLORS } from './verse-action-popover'; import { buildVerseReference, buildVerseShareText, joinVerseTexts } from '@/lib/verse-share'; +import { ReuseShadowBoundary } from '@/lib/shadow-isolation'; import { fillFor } from '@/test/highlights-test-utils'; import { installResizeObserverStub } from '@/test/dom-stubs'; @@ -186,7 +187,11 @@ function wrapReader( props: Partial = {}, overrides: HookOverrides = defaultOverrides(), ) { - return {readerJsx(props)}; + return ( + + {readerJsx(props)} + + ); } function renderReader(props: Partial = {}, overrides?: HookOverrides) { diff --git a/packages/ui/src/components/bible-reader.stories.tsx b/packages/ui/src/components/bible-reader.stories.tsx index 9bc119c8..3178ab7e 100644 --- a/packages/ui/src/components/bible-reader.stories.tsx +++ b/packages/ui/src/components/bible-reader.stories.tsx @@ -15,6 +15,30 @@ import { } from './bible-reader'; import { VerseActionPopover } from './verse-action-popover'; +async function getPickerQueries(container: ParentNode, triggerName: RegExp | string) { + return waitFor(() => { + for (const host of container.querySelectorAll('[data-yv-shadow-host]')) { + if (!host.shadowRoot) continue; + const content = host.shadowRoot.querySelector( + '[data-yv-shadow-content-wrapper]', + ); + if (!content) continue; + const picker = within(content); + const trigger = picker.queryByRole('button', { name: triggerName }); + if (trigger) return { root: host.shadowRoot, picker, trigger }; + } + throw new Error('picker shadow root not rendered'); + }); +} + +async function getPickerOverlay(root: ShadowRoot) { + return waitFor(() => { + const overlay = root.querySelector('[data-yv-shadow-local-overlay]'); + if (!overlay) throw new Error('picker overlay not rendered'); + return within(overlay); + }); +} + type PathParams = { id?: string | readonly string[]; usfm?: string | readonly string[]; @@ -473,9 +497,13 @@ export const ForcedRtlChromeGeometry: Story = { ), play: async ({ canvasElement }) => { - const previous = await screen.findByRole('button', { name: 'Previous chapter' }); - const chapter = screen.getByRole('button', { name: 'Change Bible book and chapter' }); - const next = screen.getByRole('button', { name: 'Next chapter' }); + const { + root: chapterRoot, + picker: chapterPicker, + trigger: chapter, + } = await getPickerQueries(canvasElement, 'Change Bible book and chapter'); + 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"]'); await expect(element).toBeInTheDocument(); @@ -495,8 +523,8 @@ export const ForcedRtlChromeGeometry: Story = { await expect(chapter).toBeEnabled(); }); await userEvent.click(chapter); - const chapterDialog = await screen.findByRole('dialog'); - const chapterCanvas = within(chapterDialog); + const chapterCanvas = await getPickerOverlay(chapterRoot); + const chapterDialog = chapterCanvas.getByRole('dialog'); const intro = chapterCanvas.getByTestId('intro-chapter-button'); const chapterOne = chapterCanvas.getByRole('button', { name: '1' }); const chapterTwo = chapterCanvas.getByRole('button', { name: '2' }); @@ -517,9 +545,12 @@ export const ForcedRtlChromeGeometry: Story = { ); await userEvent.click(chapterCanvas.getByRole('button', { name: 'Close' })); - await userEvent.click(screen.getByRole('button', { name: 'Change Bible version' })); - const versionDialog = await screen.findByRole('dialog'); - const versionCanvas = within(versionDialog); + const { root: versionRoot, trigger: versionTrigger } = await getPickerQueries( + canvasElement, + 'Change Bible version', + ); + await userEvent.click(versionTrigger); + const versionCanvas = await getPickerOverlay(versionRoot); const firstVersion = versionCanvas.getAllByRole('listitem')[0]!; const tile = firstVersion.querySelector('[data-slot="item-media"]')!; const label = firstVersion.querySelector('[data-slot="item-content"]')!; @@ -1101,10 +1132,13 @@ export const WithoutAuth: Story = { await expect(userMenuTrigger).not.toBeInTheDocument(); // Chapter picker and version picker should still work - const chapterButton = screen.getByRole('button', { name: /change bible book and chapter/i }); + const { trigger: chapterButton } = await getPickerQueries( + canvasElement, + /change bible book and chapter/i, + ); await expect(chapterButton).toBeInTheDocument(); - const versionButton = await screen.findByRole('button', { name: /bible version/i }); + const { trigger: versionButton } = await getPickerQueries(canvasElement, /bible version/i); await expect(versionButton).toBeInTheDocument(); // Settings should still work @@ -1149,9 +1183,9 @@ export const VersionButtonLoadingStates: Story = { ), - play: async () => { + play: async ({ canvasElement }) => { // Wait for the toolbar to mount, then capture the button in loading state - const versionButton = await screen.findByRole('button', { name: /bible version/i }); + const { trigger: versionButton } = await getPickerQueries(canvasElement, /bible version/i); // The delayed MSW handler guarantees the loading state is visible const spinner = versionButton.querySelector('[role="status"]'); @@ -1237,11 +1271,12 @@ export const JoshuaIntroChapter: Story = { await expect(verseContainer.textContent).toContain('Yahweh'); // Toolbar trigger should show "Intro" (title-case label), NOT "INTRO" (raw chapter ID) + const { trigger: chapterButton } = await getPickerQueries( + canvasElement, + /change bible book and chapter/i, + ); await waitFor( async () => { - const chapterButton = screen.getByRole('button', { - name: /change bible book and chapter/i, - }); await expect(chapterButton.textContent).toContain('Intro'); await expect(chapterButton.textContent).not.toContain('INTRO'); }, @@ -1424,7 +1459,11 @@ export const ChapterChangeLoadingOverlay: Story = { { timeout: 5000 }, ); - const nextButton = screen.getByRole('button', { name: /next chapter/i }); + const { picker: chapterPicker } = await getPickerQueries( + canvasElement, + /change bible book and chapter/i, + ); + const nextButton = chapterPicker.getByRole('button', { name: /next chapter/i }); await userEvent.click(nextButton); const rendererAfterClick = canvasElement.querySelector('[data-slot="yv-bible-renderer"]'); diff --git a/packages/ui/src/components/bible-reader.test.tsx b/packages/ui/src/components/bible-reader.test.tsx index dacdbae4..dd5b8153 100644 --- a/packages/ui/src/components/bible-reader.test.tsx +++ b/packages/ui/src/components/bible-reader.test.tsx @@ -10,6 +10,7 @@ import type { BibleBook, BibleVersion } from '@youversion/platform-core'; import type { HookOverrides } from '@youversion/platform-react-hooks'; import { HookOverrideProvider } from '@/test/hook-overrides'; import { InterfaceDirectionProvider } from '@/lib/direction'; +import { ReuseShadowBoundary } from '@/lib/shadow-isolation'; import { BIBLE_READER_SPACING, BibleReader, @@ -79,7 +80,11 @@ function defaultOverrides(): HookOverrides { } function renderWithOverrides(ui: ReactElement) { - return render({ui}); + return render( + + {ui} + , + ); } function overridesRecordingVersionLanguage() { diff --git a/packages/ui/src/components/bible-version-picker.shadow-isolation.stories.tsx b/packages/ui/src/components/bible-version-picker.shadow-isolation.stories.tsx index 55c6c96e..755ea9d8 100644 --- a/packages/ui/src/components/bible-version-picker.shadow-isolation.stories.tsx +++ b/packages/ui/src/components/bible-version-picker.shadow-isolation.stories.tsx @@ -5,29 +5,44 @@ import { http, HttpResponse } from 'msw'; import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { expect, userEvent, within } from 'storybook/test'; +import { ReuseShadowBoundary } from '../lib/shadow-isolation'; import { ShadowRootHost } from '../lib/shadow-root-host'; import { globalHandlers } from '../test/mocks/handlers'; import { waitFor, waitForElement, waitForShadowRoot } from '../test/storybook-dom'; import { BibleVersionPicker } from './bible-version-picker'; -type PortalStrategy = 'local-inline' | 'local-top-layer'; - -function IsolatedBibleVersionPicker({ +function AutomaticBibleVersionPicker({ side = 'top', - portalStrategy = 'local-top-layer', }: { side?: 'top' | 'right' | 'bottom' | 'left'; - portalStrategy?: PortalStrategy; }): React.ReactNode { const [versionId, setVersionId] = useState(111); return ( - - - - - - + + + + + ); +} + +function CustomTriggerBibleVersionPicker(): React.ReactNode { + const [versionId, setVersionId] = useState(111); + + return ( + + + + + + ); } @@ -37,18 +52,20 @@ function TwoPickersInOneIsland(): React.ReactNode { return ( -
- - - - -
-
- - - - -
+ +
+ + + + +
+
+ + + + +
+
); } @@ -83,7 +100,7 @@ function SameOriginIframeHarness(): React.ReactNode { {container ? createPortal( - + , container, ) @@ -93,8 +110,8 @@ function SameOriginIframeHarness(): React.ReactNode { } const meta = { - title: 'Spikes/BibleVersionPicker Shadow DOM isolation', - component: IsolatedBibleVersionPicker, + title: 'Components/BibleVersionPicker/Shadow isolation', + component: AutomaticBibleVersionPicker, tags: ['integration', 'shadow-dom'], parameters: { layout: 'centered', @@ -107,7 +124,7 @@ const meta = { ], }, }, -} satisfies Meta; +} satisfies Meta; export default meta; type Story = StoryObj; @@ -178,13 +195,61 @@ async function expectLogicalPadding( await expect(style.paddingInlineEnd).toBe(expected.inline); } +export const ConsumerSuppliedTriggerRemainsTheInteractiveElement: Story = { + render: () => , + play: async ({ canvasElement }) => { + const root = await waitForShadowRoot(canvasElement); + const trigger = await waitForElement( + root, + 'button.consumer-custom-trigger', + 'consumer trigger not rendered', + ); + await expect(trigger).toHaveAttribute('data-consumer-attribute', 'preserved'); + await expect(trigger).toHaveStyle({ + backgroundColor: 'rgb(12, 34, 56)', + color: 'rgb(255, 255, 255)', + }); + const triggerStyle = getComputedStyle(trigger); + const embeddedBorderRadius = triggerStyle.borderRadius; + await expect(triggerStyle.paddingBlockStart).toBe('8px'); + await expect(triggerStyle.paddingInlineStart).toBe('16px'); + await expect(embeddedBorderRadius).not.toBe('0px'); + + const hostileStyle = canvasElement.ownerDocument.createElement('style'); + hostileStyle.textContent = ` + .consumer-custom-trigger { + background-color: rgb(185, 28, 28) !important; + border-radius: 0 !important; + color: rgb(255, 255, 0) !important; + } + `; + try { + canvasElement.ownerDocument.head.append(hostileStyle); + await expect(getComputedStyle(trigger).backgroundColor).toBe('rgb(12, 34, 56)'); + await expect(getComputedStyle(trigger).borderRadius).toBe(embeddedBorderRadius); + await expect(getComputedStyle(trigger).color).toBe('rgb(255, 255, 255)'); + + await userEvent.click(trigger); + const overlay = await waitForElement( + root, + '[data-yv-shadow-local-overlay]', + 'custom-trigger picker overlay not rendered', + ); + await expect(trigger).toHaveAttribute('aria-expanded', 'true'); + await expect(within(overlay).getByRole('dialog')).toBeInTheDocument(); + } finally { + hostileStyle.remove(); + } + }, +}; + export const TopLayerEscapesClippingAndPreservesSemantics: Story = { render: () => (
- +
), play: async ({ canvasElement }) => { @@ -245,85 +310,13 @@ export const TopLayerEscapesClippingAndPreservesSemantics: Story = { }, }; -export const InlineControlIsClippedByItsAncestor: Story = { - render: () => ( -
-
- - Inline portal: intentionally clipped - - - The menu opens below the trigger, but only the portion inside the dashed frame remains - visible. - -
-
- -
-
- ), - play: async ({ canvasElement }) => { - const clippingContainer = await waitForElement( - canvasElement, - '[data-testid="clipping-container"]', - 'clipping container not rendered', - ); - const root = await waitForShadowRoot(clippingContainer); - await expect(root.querySelector('[data-yv-shadow-inline-overlay]')).toBeNull(); - await userEvent.click(await getTrigger(root)); - const inlineContainer = await waitForElement( - root, - '[data-yv-shadow-inline-overlay]', - 'inline portal container not created', - ); - const panel = await waitForElement( - inlineContainer, - '[data-slot="popover-content"]', - 'inline picker panel not rendered', - ); - - const clippingRect = clippingContainer.getBoundingClientRect(); - const panelRect = panel.getBoundingClientRect(); - await expect(panelRect.bottom).toBeGreaterThan(clippingRect.bottom + 1); - const sampleX = panelRect.left + panelRect.width / 2; - const visibleSampleY = Math.max(panelRect.top + 2, clippingRect.top + 2); - if (visibleSampleY >= clippingRect.bottom) { - throw new Error('panel has no visible area inside its clipping ancestor'); - } - const visibleHit = root.elementFromPoint(sampleX, visibleSampleY); - await expect(visibleHit === panel || (visibleHit !== null && panel.contains(visibleHit))).toBe( - true, - ); - - const clippedSampleY = Math.max(panelRect.top + 2, clippingRect.bottom + 2); - if (clippedSampleY >= panelRect.bottom) { - throw new Error('panel did not extend beyond its clipping ancestor'); - } - const clippedHit = root.elementFromPoint(sampleX, clippedSampleY); - await expect(clippedHit === panel || (clippedHit !== null && panel.contains(clippedHit))).toBe( - false, - ); - }, -}; - export const StylingFocusDismissalAndRapidReopen: Story = { render: () => (
- +
), play: async ({ canvasElement }) => { @@ -419,7 +412,7 @@ export const ProviderDirectionRejectsHostVisualValues: Story = { style={{ display: 'flex', alignItems: 'center', gap: 24 }} > Host control - + ), play: async ({ canvasElement }) => { @@ -462,7 +455,7 @@ export const ProviderDirectionRejectsHostVisualValues: Story = { } const languageTabsInlineSize = languageTabs.offsetWidth; const languageInputRadius = ownerWindow.getComputedStyle(languageInputGroup).borderRadius; - const panelGeometry = geometrySnapshot(panel); + const panelInlineSize = panel.offsetWidth; await expect(languageTabsInlineSize).toBe(languageTabsContainer.clientWidth - 32); await expect(languageInputRadius).toBe('30px'); @@ -522,7 +515,7 @@ export const ProviderDirectionRejectsHostVisualValues: Story = { ); await waitFor(async () => { await expect(geometrySnapshot(trigger)).toEqual(triggerGeometry); - await expect(geometrySnapshot(panel)).toEqual(panelGeometry); + await expect(panel.offsetWidth).toBe(panelInlineSize); await expect(geometrySnapshot(inputGroup)).toEqual(inputGeometry); }); } finally { @@ -535,10 +528,10 @@ export const MultiplePickersCreateIndependentLazyContainers: Story = { render: () => (
- +
- +
), @@ -628,12 +621,12 @@ export const PanelTracksAncestorScrollAndVersionListScrollsInternally: Story = { >
- +
- +
), diff --git a/packages/ui/src/components/bible-version-picker.stories.tsx b/packages/ui/src/components/bible-version-picker.stories.tsx index f30b214c..5ea3f88b 100644 --- a/packages/ui/src/components/bible-version-picker.stories.tsx +++ b/packages/ui/src/components/bible-version-picker.stories.tsx @@ -1,12 +1,35 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { BibleVersionPicker, type RootProps } from './bible-version-picker'; import { useState } from 'react'; -import { screen, userEvent, within, expect, waitFor } from 'storybook/test'; +import { fireEvent, userEvent, within, expect, waitFor } from 'storybook/test'; import { http, HttpResponse, delay } from 'msw'; import { BookOpenIcon } from './icons/book-open'; import { Button } from './ui/button'; import { RECENT_VERSIONS_KEY } from './bible-version-picker'; import i18n from '@/i18n'; +import { waitForElement, waitForShadowRoot } from '../test/storybook-dom'; + +async function getPickerQueries(container: ParentNode) { + const root = await waitForShadowRoot(container); + const content = await waitForElement( + root, + '[data-yv-shadow-content-wrapper]', + 'version picker content not rendered', + ); + return { root, picker: within(content) }; +} + +async function openPicker(container: ParentNode, triggerName: RegExp = /NIV/i) { + const { root, picker } = await getPickerQueries(container); + const trigger = await picker.findByRole('button', { name: triggerName }, { timeout: 10_000 }); + await userEvent.click(trigger); + const overlayElement = await waitForElement( + root, + '[data-yv-shadow-local-overlay]', + 'version picker overlay not rendered', + ); + return { picker, overlay: within(overlayElement) }; +} type StoredRecentVersion = { id: number; @@ -154,39 +177,33 @@ export const InteractiveLanguageSelection: Story = { }, tags: ['integration'], play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Open popover - const trigger = await canvas.findByRole('button', { name: /NIV/i }, { timeout: 10_000 }); - await userEvent.click(trigger); + const { overlay } = await openPicker(canvasElement); - // Validate the dialog is open - // P.S. I use screen here because the Popover uses a Portal that moves - // the element out of the original canvas element. - const dialog = await screen.findByRole('dialog'); + // Validate the dialog in the picker root's shadow-local overlay. + const dialog = await overlay.findByRole('dialog'); await expect(dialog).toBeInTheDocument(); // Click language button - const languageButton = await screen.findByRole('button', { name: /select a language/i }); + const languageButton = await overlay.findByRole('button', { name: /select a language/i }); await waitFor(async () => { await expect(languageButton).toHaveTextContent('English'); }); await userEvent.click(languageButton); // Verify language list is visible - const selectLanguageHeading = await screen.findByRole('heading', { + const selectLanguageHeading = await overlay.findByRole('heading', { name: /select a language/i, }); await expect(selectLanguageHeading).toBeInTheDocument(); // Select Korean - const koreanOption = await screen.findByRole('listitem', { name: /korean/i }); + const koreanOption = await overlay.findByRole('listitem', { name: /korean/i }); await userEvent.click(koreanOption); // Wait for the language button to update with the selected language await waitFor(async () => { await expect( - await screen.findByRole('button', { name: /select a language/i }), + await overlay.findByRole('button', { name: /select a language/i }), ).toHaveTextContent(/korean/i); }); }, @@ -215,8 +232,6 @@ export const SuggestedLanguagesTabs: Story = { }; }, play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - // The ko-KR stub above exists to drive the *suggested languages* list, but // YouVersionProvider also syncs the SDK's own UI language from // navigator.languages on mount — and we ship a Korean bundle. Pin the UI back @@ -224,27 +239,27 @@ export const SuggestedLanguagesTabs: Story = { // assertions below can match English labels. await i18n.changeLanguage('en'); - // Open popover - const trigger = await canvas.findByRole('button', { name: /NIV/i }, { timeout: 10_000 }); - await userEvent.click(trigger); + const { overlay } = await openPicker(canvasElement); // Validate the dialog is open - const dialog = await screen.findByRole('dialog'); + const dialog = await overlay.findByRole('dialog'); await expect(dialog).toBeInTheDocument(); // Click language button to open language selection - const languageButton = await screen.findByRole('button', { name: /select a language/i }); + const languageButton = await overlay.findByRole('button', { name: /select a language/i }); await userEvent.click(languageButton); // Verify the Suggested tab is active by default and shows "Regional" heading - const suggestedTab = await screen.findByRole('tab', { name: /suggested/i }); - await expect(suggestedTab).toHaveAttribute('data-state', 'active'); + const suggestedTab = await overlay.findByRole('tab', { name: /suggested/i }); + await waitFor(async () => { + await expect(suggestedTab).toHaveAttribute('data-state', 'active'); + }); - const regionalHeading = await screen.findByRole('heading', { name: /regional/i }); + const regionalHeading = await overlay.findByRole('heading', { name: /regional/i }); await expect(regionalHeading).toBeInTheDocument(); // Find the active tab panel (Suggested) to scope our language queries - const suggestedTabPanel = await screen.findByRole('tabpanel'); + const suggestedTabPanel = await overlay.findByRole('tabpanel'); // Verify user's browser languages appear at the top of suggested languages // We mocked navigator.languages to ['en-US', 'ko-KR'], so English should be first, Korean second @@ -253,7 +268,7 @@ export const SuggestedLanguagesTabs: Story = { await expect(suggestedLanguageItems[0]).toHaveAttribute('aria-label', 'Korean'); // Verify the All tab exists with language count - const allTab = await screen.findByRole('tab', { name: /all/i }); + const allTab = await overlay.findByRole('tab', { name: /all/i }); await expect(allTab).toBeInTheDocument(); // Switch to All tab @@ -262,11 +277,11 @@ export const SuggestedLanguagesTabs: Story = { await expect(suggestedTab).toHaveAttribute('data-state', 'inactive'); // Verify All Languages heading is shown - const allLanguagesHeading = await screen.findByRole('heading', { name: /all languages/i }); + const allLanguagesHeading = await overlay.findByRole('heading', { name: /all languages/i }); await expect(allLanguagesHeading).toBeInTheDocument(); // Find the active tab panel (All) to scope our language queries - const allTabPanel = await screen.findByRole('tabpanel'); + const allTabPanel = await overlay.findByRole('tabpanel'); // Verify languages in All tab are sorted alphabetically by display name // English comes before Korean alphabetically @@ -281,7 +296,7 @@ export const SuggestedLanguagesTabs: Story = { await expect(suggestedTab).toHaveAttribute('data-state', 'active'); // Find the active tab panel again after switching tabs - const suggestedTabPanelAgain = await screen.findByRole('tabpanel'); + const suggestedTabPanelAgain = await overlay.findByRole('tabpanel'); const englishOption = await within(suggestedTabPanelAgain).findByRole('listitem', { name: /english/i, }); @@ -290,7 +305,7 @@ export const SuggestedLanguagesTabs: Story = { // Verify the language button shows the selected language await waitFor(async () => { await expect( - await screen.findByRole('button', { name: /select a language/i }), + await overlay.findByRole('button', { name: /select a language/i }), ).toHaveTextContent(/english/i); }); }, @@ -316,25 +331,23 @@ export const SuggestedLanguagesOrder: Story = { }; }, play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Open popover - const trigger = await canvas.findByRole('button', { name: /NIV/i }, { timeout: 10_000 }); - await userEvent.click(trigger); + const { overlay } = await openPicker(canvasElement); // Open language selection - const languageButton = await screen.findByRole('button', { name: /select a language/i }); + const languageButton = await overlay.findByRole('button', { name: /select a language/i }); await userEvent.click(languageButton); // Verify suggested languages appear in API order (not alphabetical or by population) - const suggestedTabPanel = await screen.findByRole('tabpanel'); - const suggestedLanguageItems = within(suggestedTabPanel).getAllByRole('listitem'); - const labels = suggestedLanguageItems.map((item) => item.getAttribute('aria-label')); - - await expect(labels[0]).toBe('English'); - await expect(labels[1]).toBe('Spanish'); - await expect(labels[2]).toMatch(/Portuguese/); - await expect(labels[3]).toBe('French'); + const suggestedTabPanel = await overlay.findByRole('tabpanel'); + await waitFor(async () => { + const labels = within(suggestedTabPanel) + .getAllByRole('listitem') + .map((item) => item.getAttribute('aria-label')); + await expect(labels[0]).toBe('English'); + await expect(labels[1]).toBe('Spanish'); + await expect(labels[2]).toMatch(/Portuguese/); + await expect(labels[3]).toBe('French'); + }); }, }; @@ -344,29 +357,31 @@ export const LanguageSearch: Story = { }, tags: ['integration'], play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - const trigger = await canvas.findByRole('button', { name: /NIV/i }, { timeout: 10_000 }); - await userEvent.click(trigger); + const { overlay } = await openPicker(canvasElement); - const languageButton = await screen.findByRole('button', { name: /select a language/i }); + const languageButton = await overlay.findByRole('button', { name: /select a language/i }); await userEvent.click(languageButton); - const languageSearchInput = screen.getByRole('textbox', { name: /search languages/i }); + const languageSearchInput = overlay.getByRole('textbox', { name: /search languages/i }); await userEvent.type(languageSearchInput, 'Korean', { delay: 50 }); - await expect(screen.queryByRole('tab', { name: /suggested/i })).not.toBeInTheDocument(); - await expect(screen.queryByRole('tab', { name: /all/i })).not.toBeInTheDocument(); + await waitFor(async () => { + await expect(overlay.queryByRole('tab', { name: /suggested/i })).not.toBeInTheDocument(); + await expect(overlay.queryByRole('tab', { name: /all/i })).not.toBeInTheDocument(); + }); - const results = await screen.findByTestId('language-search-results'); + const results = await overlay.findByTestId('language-search-results'); await expect(within(results).getAllByRole('listitem')).toHaveLength(1); await expect(within(results).getByRole('listitem', { name: /korean/i })).toBeInTheDocument(); - await userEvent.clear(languageSearchInput); - await userEvent.type(languageSearchInput, 'Koreanea', { delay: 50 }); + const currentLanguageSearchInput = overlay.getByRole('textbox', { + name: /search languages/i, + }); + await fireEvent.input(currentLanguageSearchInput, { target: { value: '' } }); + await userEvent.type(currentLanguageSearchInput, 'Koreanea', { delay: 50 }); await expect( - screen.getByText("We're sorry, there are no results for this search."), + overlay.getByText("We're sorry, there are no results for this search."), ).toBeInTheDocument(); }, }; @@ -377,21 +392,17 @@ export const InteractiveVersionSearch: Story = { }, tags: ['integration'], play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Open popover - const trigger = canvas.getByRole('button', { name: /select|111|1/i }); - await userEvent.click(trigger); + const { overlay } = await openPicker(canvasElement, /select|111|1/i); // Type in search - const searchInput = screen.getByRole('textbox', { name: /search bible versions/i }); + const searchInput = overlay.getByRole('textbox', { name: /search bible versions/i }); await userEvent.type(searchInput, 'NIV', { delay: 50 }); // Verify search is working (versions should be filtered) await expect(searchInput).toHaveValue('NIV'); await expect( - await screen.findByRole( + await overlay.findByRole( 'listitem', { name: /new international version 2011/i }, { timeout: 5_000 }, @@ -416,27 +427,25 @@ export const RecentVersionsSelection: Story = { versionId: 111, }, tags: ['integration'], - play: async () => { - // Open popover - const trigger = await screen.findByRole('button', { name: /NIV/i }, { timeout: 10_000 }); - await userEvent.click(trigger); + play: async ({ canvasElement }) => { + const { picker, overlay } = await openPicker(canvasElement); // Verify initially there's no recent versions section - const dialog = await screen.findByRole('dialog'); + const dialog = await overlay.findByRole('dialog'); await expect(dialog).toBeInTheDocument(); - await expect(screen.queryByText('Recently Used Versions')).not.toBeInTheDocument(); + await expect(overlay.queryByText('Recently Used Versions')).not.toBeInTheDocument(); // Select a different version (Amplified Bible) - const ampOption = await screen.findByRole('listitem', { name: /Amplified Bible/i }); + const ampOption = await overlay.findByRole('listitem', { name: /Amplified Bible/i }); await userEvent.click(ampOption); // Reopen the popover and verify the recent versions section now appears - const updatedTrigger = await screen.findByRole('button', { name: /AMP/i }); + const updatedTrigger = await picker.findByRole('button', { name: /AMP/i }); await expect(updatedTrigger).toBeInTheDocument(); await userEvent.click(updatedTrigger); - await expect(await screen.findByText('Recently Used Versions')).toBeInTheDocument(); - const recentVersionList = await screen.findByTestId('recent-version-list'); + await expect(await overlay.findByText('Recently Used Versions')).toBeInTheDocument(); + const recentVersionList = await overlay.findByTestId('recent-version-list'); await expect(within(recentVersionList).getByText(/Amplified Bible/i)).toBeInTheDocument(); // Verify localStorage was updated @@ -445,24 +454,24 @@ export const RecentVersionsSelection: Story = { await expect(storedVersions[0]?.title).toContain('Amplified'); // Now select NIV from the main list - const nivOption = await screen.findByRole('listitem', { + const nivOption = await overlay.findByRole('listitem', { name: /New International Version 2011/i, }); await userEvent.click(nivOption); // Reopen and select AMP from recent versions - const nivTrigger = await screen.findByRole('button', { name: /NIV/i }); + const nivTrigger = await picker.findByRole('button', { name: /NIV/i }); await expect(nivTrigger).toBeInTheDocument(); await userEvent.click(nivTrigger); - const recentList = await screen.findByTestId('recent-version-list'); + const recentList = await overlay.findByTestId('recent-version-list'); const ampRecentOption = within(recentList).getByRole('listitem', { name: /Amplified Bible/i, }); await userEvent.click(ampRecentOption); // Verify AMP is selected and moved to top of recent versions - const ampTrigger = await screen.findByRole('button', { name: /AMP/i }); + const ampTrigger = await picker.findByRole('button', { name: /AMP/i }); await expect(ampTrigger).toBeInTheDocument(); storedVersions = getStoredRecentVersions(); @@ -476,27 +485,23 @@ export const SearchResetsAfterSelection: Story = { }, tags: ['integration'], play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Open popover - const trigger = await canvas.findByRole('button', { name: /NIV/i }, { timeout: 10_000 }); - await userEvent.click(trigger); + const { picker, overlay } = await openPicker(canvasElement); // Type in search - const searchInput = screen.getByRole('textbox', { name: /search bible versions/i }); + const searchInput = overlay.getByRole('textbox', { name: /search bible versions/i }); await userEvent.type(searchInput, 'Amplified', { delay: 50 }); await expect(searchInput).toHaveValue('Amplified'); // Select a version from the filtered results - const ampOption = await screen.findByRole('listitem', { name: /Amplified Bible/i }); + const ampOption = await overlay.findByRole('listitem', { name: /Amplified Bible/i }); await userEvent.click(ampOption); // Reopen the popover - const updatedTrigger = await canvas.findByRole('button', { name: /AMP/i }); + const updatedTrigger = await picker.findByRole('button', { name: /AMP/i }); await userEvent.click(updatedTrigger); // Verify search input is cleared - const resetSearchInput = screen.getByRole('textbox', { name: /search bible versions/i }); + const resetSearchInput = overlay.getByRole('textbox', { name: /search bible versions/i }); await expect(resetSearchInput).toHaveValue(''); }, }; @@ -516,29 +521,30 @@ export const RecentVersionsSearchFilter: Story = { ]), ); }, - play: async () => { - // Open popover - const trigger = await screen.findByRole('button', { name: /NIV/i }, { timeout: 10_000 }); - await userEvent.click(trigger); + play: async ({ canvasElement }) => { + const { overlay } = await openPicker(canvasElement); // Verify recent versions are displayed - await expect(await screen.findByText('Recently Used Versions')).toBeInTheDocument(); - const recentVersionList = await screen.findByTestId('recent-version-list'); + await expect(await overlay.findByText('Recently Used Versions')).toBeInTheDocument(); + const recentVersionList = await overlay.findByTestId('recent-version-list'); await expect(within(recentVersionList).getByText(/Amplified Bible/i)).toBeInTheDocument(); await expect( within(recentVersionList).getByText(/American Standard Version/i), ).toBeInTheDocument(); // Search for "ASV" - const searchInput = screen.getByRole('textbox', { name: /search bible versions/i }); + const searchInput = overlay.getByRole('textbox', { name: /search bible versions/i }); await userEvent.type(searchInput, 'ASV', { delay: 50 }); // Verify only ASV appears in recent versions after filtering await waitFor(async () => { - await expect(screen.queryByText(/Amplified Bible/i)).not.toBeInTheDocument(); + const filteredRecentList = await overlay.findByTestId('recent-version-list'); + await expect( + within(filteredRecentList).queryByText(/Amplified Bible/i), + ).not.toBeInTheDocument(); }); await expect( - within(await screen.findByTestId('recent-version-list')).getByText( + within(await overlay.findByTestId('recent-version-list')).getByText( /American Standard Version/i, ), ).toBeInTheDocument(); @@ -561,13 +567,11 @@ export const RecentVersionsMaxLimit: Story = { ]), ); }, - play: async () => { - // Open popover - const trigger = await screen.findByRole('button', { name: /NIV/i }, { timeout: 10_000 }); - await userEvent.click(trigger); + play: async ({ canvasElement }) => { + const { overlay } = await openPicker(canvasElement); // Select a new version (NASB2020) - this should push out ASV - const nasbOption = await screen.findByRole('listitem', { + const nasbOption = await overlay.findByRole('listitem', { name: /New American Standard Bible 2020/i, }); await userEvent.click(nasbOption); diff --git a/packages/ui/src/components/bible-version-picker.test.tsx b/packages/ui/src/components/bible-version-picker.test.tsx index 8b6413c1..40a2c30b 100644 --- a/packages/ui/src/components/bible-version-picker.test.tsx +++ b/packages/ui/src/components/bible-version-picker.test.tsx @@ -27,6 +27,7 @@ import { } from '@youversion/platform-core'; import { HookOverrideProvider } from '@/test/hook-overrides'; import { InterfaceDirectionProvider } from '@/lib/direction'; +import { ReuseShadowBoundary } from '@/lib/shadow-isolation'; const mockVersions: BibleVersion[] = [ { @@ -156,7 +157,9 @@ function defaultOverrides({ function renderWithOverrides(ui: ReactElement, setup?: OverrideSetup) { return render(ui, { wrapper: ({ children }) => ( - {children} + + {children} + ), }); } diff --git a/packages/ui/src/components/bible-version-picker.tsx b/packages/ui/src/components/bible-version-picker.tsx index 6e19b86c..42651b1c 100644 --- a/packages/ui/src/components/bible-version-picker.tsx +++ b/packages/ui/src/components/bible-version-picker.tsx @@ -45,6 +45,7 @@ import { Item, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } f import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; import { useInterfaceDirection } from '@/lib/direction'; +import { ShadowIsolationBoundary } from '@/lib/shadow-isolation'; export const RECENT_VERSIONS_KEY = 'youversion-platform:picker:recent-versions'; const MAX_RECENT_VERSIONS = 3; @@ -291,7 +292,7 @@ export type BibleVersionPickerLanguageTriggerProps = Omit< 'children' >; -function Root({ +function RootImplementation({ versionId: controlledVersionId, onVersionChange, languageId: controlledLanguageId, @@ -518,6 +519,14 @@ function Root({ ); } +function Root(props: RootProps) { + return ( + + + + ); +} + /** * Props for the version picker trigger. Interface direction owns the rendered * trigger's `dir`; a consumer-supplied `dir` is intentionally ignored. @@ -748,6 +757,7 @@ function Content({ open, onRequestClose }: BibleVersionPickerContentProps = {}) >
setIsPopoverOpen(false)} />
setSearchQuery(e.target.value)} + onInput={(event) => setSearchQuery(event.currentTarget.value)} > @@ -1087,7 +1098,7 @@ export function BibleLanguagePickerContent({ placeholder={t('searchPlaceholder')} dir="auto" value={languageSearchQuery} - onChange={(e) => setLanguageSearchQuery(e.target.value)} + onInput={(event) => setLanguageSearchQuery(event.currentTarget.value)} /> diff --git a/packages/ui/src/lib/shadow-isolation.tsx b/packages/ui/src/lib/shadow-isolation.tsx index b1771214..cd100748 100644 --- a/packages/ui/src/lib/shadow-isolation.tsx +++ b/packages/ui/src/lib/shadow-isolation.tsx @@ -12,6 +12,32 @@ import { ShadowRootHost } from './shadow-root-host'; const ShadowBoundaryReuseContext = createContext(false); +type ShadowPortalStrategy = 'local-inline' | 'local-top-layer'; + +interface ShadowIsolationBoundaryProps { + children: ReactNode; + /** @internal Component-owned overlay strategy; not a public configuration surface. */ + portalStrategy?: ShadowPortalStrategy; +} + +/** @internal Applies automatic isolation while honoring SDK-owned boundary reuse. */ +export function ShadowIsolationBoundary({ + children, + portalStrategy, +}: ShadowIsolationBoundaryProps): ReactNode { + const reuseBoundary = useContext(ShadowBoundaryReuseContext); + + if (reuseBoundary) { + return ( + + {children} + + ); + } + + return {children}; +} + /** @internal Marks SDK-owned composition that must reuse an existing automatic boundary. */ export function ReuseShadowBoundary({ children }: { children: ReactNode }): ReactNode { return ( @@ -25,22 +51,15 @@ export function withShadowIsolation

( displayName: string, ): ForwardRefExoticComponent & RefAttributes> { const Isolated = forwardRef((props, ref) => { - const reuseBoundary = useContext(ShadowBoundaryReuseContext); const implementationProps: PropsWithoutRef

& RefAttributes = { ...props, ref, }; - if (reuseBoundary) { - return ( - - {createElement(Implementation, implementationProps)} - - ); - } - return ( - {createElement(Implementation, implementationProps)} + + {createElement(Implementation, implementationProps)} + ); }); Isolated.displayName = displayName; diff --git a/packages/ui/src/lib/shadow-root-host.tsx b/packages/ui/src/lib/shadow-root-host.tsx index 0354dd72..6097b48d 100644 --- a/packages/ui/src/lib/shadow-root-host.tsx +++ b/packages/ui/src/lib/shadow-root-host.tsx @@ -171,11 +171,11 @@ function hidePopoverIfOpen(container: HTMLElement | null): void { interface ShadowRootHostProps { children: ReactNode; - /** @internal Spike-only, mount-stable strategy; omit for leaves without overlays. */ + /** @internal Component-owned, mount-stable strategy; omit for leaves without overlays. */ portalStrategy?: ShadowPortalStrategy; } -/** @internal Proof-of-concept primitive; not part of the public API. */ +/** @internal Shadow boundary primitive; not part of the public API. */ export function ShadowRootHost({ children, portalStrategy }: ShadowRootHostProps): ReactNode { const hostRef = useRef(null); const shadowRootRef = useRef(null);