diff --git a/.changeset/calm-shadows-float.md b/.changeset/calm-shadows-float.md
new file mode 100644
index 00000000..21ef5647
--- /dev/null
+++ b/.changeset/calm-shadows-float.md
@@ -0,0 +1,5 @@
+---
+'@youversion/platform-react-ui': patch
+---
+
+Route isolated Radix popovers and dialogs through a shared styled shadow overlay, and constrain popovers to the available viewport height.
diff --git a/docs/adr/0005-prototype-shadow-dom-style-isolation.md b/docs/adr/0005-prototype-shadow-dom-style-isolation.md
index 6baa48b2..516e025e 100644
--- a/docs/adr/0005-prototype-shadow-dom-style-isolation.md
+++ b/docs/adr/0005-prototype-shadow-dom-style-isolation.md
@@ -58,6 +58,31 @@ pseudo-content, existing interactions, and mounting in a same-origin iframe.
Unit tests verify Strict Mode behavior and the inline-important host reset. The
remaining hostile vectors are available for manual inspection on the demo page.
+## Subsequent complex-component spike
+
+A follow-up spike rendered `BibleVersionPicker` inside the prototype boundary
+without changing the picker's public export. Radix popover content is routed to
+one shared SDK overlay shadow root under `document.body`. This preserves the CSS
+boundary while allowing floating content to escape clipping and transformed
+ancestors. The overlay root is reused by isolated components and receives the
+same SDK stylesheet as component roots.
+
+Focused Chromium stories verify portal placement, SDK styling, resistance to
+host selectors, ancestor and viewport collision handling, keyboard focus,
+Escape and outside-click dismissal, and overlay reuse. Manual Chromium
+inspection also confirmed internal scrolling at constrained viewport heights.
+The same container plumbing is available to the shared Radix dialog primitive,
+with a focused isolated `SignInDialog` browser proof.
+
+This architecture introduces a separate tree scope for triggers and their
+overlays. Although Radix emits matching `aria-controls` and content IDs for the
+popover, Chromium does not resolve `ariaControlsElements` across the two shadow
+roots. This remains an explicit assistive-technology validation gate rather
+than a proven accessible relationship. Radix's development-only dialog checks
+also search for title and description IDs through `document`, so they warn for
+elements that the browser proof confirms are present in the overlay shadow
+root; real assistive-technology behavior is still unresolved.
+
## Compatibility impact
Although the React props API is unchanged, the rendered DOM structure is not.
@@ -71,7 +96,9 @@ detail.
## Deliberately deferred
- Rollout to all exported components.
-- Radix popover/dialog portal placement and focus management.
+- Broader Radix popover/dialog rollout and assistive-technology validation. The
+ shared overlay-root mechanism and representative Chromium focus behavior are
+ proven, but package-wide and real-AT coverage are not.
- Form association when controls live outside their form's tree scope.
- SSR/hydration and the first client paint.
- A package-wide custom-property audit. `all: initial` does not reset custom
@@ -81,7 +108,10 @@ detail.
properties. Some host values may be intentional localization inputs, while
SDK-owned visual tokens need shadow-local defaults.
- Host `@font-face` rules, which are not scoped by Shadow DOM.
-- Ancestor layout constraints, which Shadow DOM cannot isolate.
+- Ancestor layout constraints on the component host, which Shadow DOM cannot
+ isolate. Portalled floating content can escape ancestor clipping through the
+ shared body-level overlay root, but the host itself can still be hidden,
+ clipped, or transformed by its ancestors.
- Event retargeting, nested-root behavior, and a supported consumer customization
model.
- Stylesheet construction/adoption failure recovery beyond feature fallback.
diff --git a/packages/ui/src/components/BibleVersionPicker.shadow-isolation.stories.tsx b/packages/ui/src/components/BibleVersionPicker.shadow-isolation.stories.tsx
new file mode 100644
index 00000000..50ab2464
--- /dev/null
+++ b/packages/ui/src/components/BibleVersionPicker.shadow-isolation.stories.tsx
@@ -0,0 +1,486 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { http, HttpResponse } from 'msw';
+import { useState } from 'react';
+import { expect, userEvent, waitFor } from 'storybook/test';
+import { ShadowRootHost } from '../lib/shadow-root-host';
+import { globalHandlers } from '../test/mocks/handlers';
+import { BibleVersionPicker } from './bible-version-picker';
+
+function IsolatedBibleVersionPicker({
+ side = 'top',
+}: {
+ side?: 'top' | 'right' | 'bottom' | 'left';
+}): React.ReactNode {
+ const [versionId, setVersionId] = useState(111);
+
+ return (
+
+
+
+
+
+
+ );
+}
+
+function SharedOverlayLifecycleHarness(): React.ReactNode {
+ const [showSecondPicker, setShowSecondPicker] = useState(true);
+
+ return (
+
+
+
+
+ {showSecondPicker ? (
+
+
+
+ ) : null}
+
setShowSecondPicker(false)}>
+ Unmount second picker
+
+
+ );
+}
+
+const meta = {
+ title: 'Spikes/BibleVersionPicker Shadow DOM isolation',
+ component: IsolatedBibleVersionPicker,
+ tags: ['integration'],
+ parameters: {
+ layout: 'centered',
+ msw: {
+ handlers: [
+ ...globalHandlers,
+ http.get('*/v1/fonts/1/stylesheet', () =>
+ HttpResponse.text('', { headers: { 'Content-Type': 'text/css' } }),
+ ),
+ ],
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+function styleSnapshot(element: HTMLElement) {
+ const ownerWindow = element.ownerDocument.defaultView;
+ if (!ownerWindow) throw new Error('element window not available');
+ const styles = ownerWindow.getComputedStyle(element);
+ return {
+ appearance: styles.appearance,
+ backgroundColor: styles.backgroundColor,
+ borderRadius: styles.borderRadius,
+ borderTopColor: styles.borderTopColor,
+ borderTopStyle: styles.borderTopStyle,
+ borderTopWidth: styles.borderTopWidth,
+ color: styles.color,
+ display: styles.display,
+ fontFamily: styles.fontFamily,
+ fontSize: styles.fontSize,
+ lineHeight: styles.lineHeight,
+ padding: styles.padding,
+ };
+}
+
+async function getComponentRoot(container: ParentNode): Promise {
+ const host = await waitFor(() => {
+ const element = container.querySelector('[data-yv-shadow-host]');
+ if (!element?.shadowRoot) throw new Error('component shadow root not attached');
+ return element;
+ });
+ return host.shadowRoot!;
+}
+
+async function getPickerTrigger(container: ParentNode): Promise {
+ const root = await getComponentRoot(container);
+ return waitFor(() => {
+ const element = root.querySelector('[data-slot="popover-trigger"]');
+ if (!element) throw new Error('version picker trigger not rendered');
+ return element;
+ });
+}
+
+async function getOverlayRoot(ownerDocument: Document): Promise {
+ const host = await waitFor(() => {
+ const element = ownerDocument.body.querySelector('[data-yv-shadow-overlay-host]');
+ if (!element?.shadowRoot) throw new Error('shared overlay shadow root not attached');
+ return element;
+ });
+ return host.shadowRoot!;
+}
+
+async function getPopoverPanel(overlayRoot: ShadowRoot): Promise {
+ return waitFor(() => {
+ const element = overlayRoot.querySelector('[data-slot="popover-content"]');
+ if (!element) throw new Error('popover panel not rendered');
+ return element;
+ });
+}
+
+async function openPicker(container: ParentNode, ownerDocument: Document) {
+ const componentRoot = await getComponentRoot(container);
+ const trigger = await getPickerTrigger(container);
+ await userEvent.click(trigger);
+ const overlayRoot = await getOverlayRoot(ownerDocument);
+ const panel = await getPopoverPanel(overlayRoot);
+ return { componentRoot, trigger, overlayRoot, panel };
+}
+
+async function getNamedPickerTrigger(
+ canvasElement: HTMLElement,
+ pickerTestId: string,
+): Promise {
+ const picker = canvasElement.querySelector(`[data-testid="${pickerTestId}"]`);
+ if (!picker) throw new Error(`${pickerTestId} not rendered`);
+ return getPickerTrigger(picker);
+}
+
+export const PortalContentRendersInsideShadowRoot: Story = {
+ tags: ['integration'],
+ play: async ({ canvasElement }) => {
+ const { componentRoot, overlayRoot, panel } = await openPicker(
+ canvasElement,
+ canvasElement.ownerDocument,
+ );
+
+ void expect(panel.getRootNode()).toBe(overlayRoot);
+ void expect(componentRoot.querySelector('[data-slot="popover-content"]')).toBeNull();
+ void expect(
+ canvasElement.ownerDocument.body.querySelector('[data-slot="popover-content"]'),
+ ).toBeNull();
+ void expect(
+ canvasElement.ownerDocument.body.querySelectorAll('[data-yv-shadow-overlay-host]'),
+ ).toHaveLength(1);
+ },
+};
+
+export const AncestorClippingAndPositioning: Story = {
+ tags: ['integration'],
+ render: () => (
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ const clippingContainer = canvasElement.querySelector(
+ '[data-testid="clipping-container"]',
+ );
+ if (!clippingContainer) throw new Error('clipping container not rendered');
+
+ const { overlayRoot, panel } = await openPicker(clippingContainer, canvasElement.ownerDocument);
+
+ const containerRect = clippingContainer.getBoundingClientRect();
+ const panelRect = panel.getBoundingClientRect();
+ void expect(panelRect.bottom).toBeGreaterThan(containerRect.bottom + 1);
+
+ const viewport = canvasElement.ownerDocument.defaultView;
+ if (!viewport) throw new Error('story window not available');
+ void expect(panelRect.top).toBeGreaterThanOrEqual(0);
+ void expect(panelRect.left).toBeGreaterThanOrEqual(0);
+ void expect(panelRect.bottom).toBeLessThanOrEqual(viewport.innerHeight);
+ void expect(panelRect.right).toBeLessThanOrEqual(viewport.innerWidth);
+
+ const sampleX = panelRect.left + panelRect.width / 2;
+ const sampleY = Math.max(panelRect.top + 2, containerRect.bottom + 2);
+ if (sampleY >= panelRect.bottom) {
+ throw new Error('popover panel did not extend far enough beyond the clipping container');
+ }
+
+ const hit = overlayRoot.elementFromPoint(sampleX, sampleY);
+ void expect(hit === panel || (hit !== null && panel.contains(hit))).toBe(true);
+ },
+};
+
+export const SharedOverlayStylesResistHostCss: Story = {
+ tags: ['integration'],
+ render: () => (
+
+
+ Host control
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ const ownerDocument = canvasElement.ownerDocument;
+ const ownerWindow = ownerDocument.defaultView;
+ if (!ownerWindow) throw new Error('story window not available');
+
+ const control = canvasElement.querySelector('[data-testid="host-control"]');
+ if (!control) throw new Error('host control not rendered');
+
+ const { panel } = await openPicker(canvasElement, ownerDocument);
+ const input = await waitFor(() => {
+ const element = panel.querySelector('input');
+ if (!element) throw new Error('popover search input not rendered');
+ return element;
+ });
+
+ const panelBaseline = styleSnapshot(panel);
+ const inputBaseline = styleSnapshot(input);
+ void expect(panelBaseline.display).toBe('grid');
+ void expect(panelBaseline.backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
+ void expect(panelBaseline.borderRadius).not.toBe('0px');
+ void expect(inputBaseline.fontFamily).toContain('Inter');
+
+ const hostileStyle = ownerDocument.createElement('style');
+ hostileStyle.textContent = `
+ button,
+ input,
+ [role='dialog'] {
+ appearance: none !important;
+ background: rgb(185, 28, 28) !important;
+ border: 10px dashed lime !important;
+ border-radius: 0 !important;
+ color: yellow !important;
+ font: 32px/1 fantasy !important;
+ padding: 40px !important;
+ }
+ `;
+
+ try {
+ ownerDocument.head.append(hostileStyle);
+ await waitFor(() => {
+ void expect(ownerWindow.getComputedStyle(control).backgroundColor).toBe('rgb(185, 28, 28)');
+ });
+
+ void expect(styleSnapshot(panel)).toEqual(panelBaseline);
+ void expect(styleSnapshot(input)).toEqual(inputBaseline);
+ } finally {
+ hostileStyle.remove();
+ }
+ },
+};
+
+export const HostSpacingCustomPropertyDoesNotAffectOverlay: Story = {
+ tags: ['integration'],
+ play: async ({ canvasElement }) => {
+ const ownerDocument = canvasElement.ownerDocument;
+ const { overlayRoot } = await openPicker(canvasElement, ownerDocument);
+ const languageTrigger = await waitFor(() => {
+ const element = overlayRoot.querySelector(
+ 'button[aria-label="Select language"]',
+ );
+ if (!element) throw new Error('language trigger not rendered');
+ return element;
+ });
+ await userEvent.click(languageTrigger);
+
+ const tabsList = await waitFor(() => {
+ const element = overlayRoot.querySelector('[data-slot="tabs-list"]');
+ if (!element) throw new Error('language tabs not rendered');
+ return element;
+ });
+ const baselineWidth = tabsList.getBoundingClientRect().width;
+ void expect(baselineWidth).toBeGreaterThan(0);
+
+ const documentRoot = ownerDocument.documentElement;
+ const previousValue = documentRoot.style.getPropertyValue('--spacing');
+ const previousPriority = documentRoot.style.getPropertyPriority('--spacing');
+
+ try {
+ documentRoot.style.setProperty('--spacing', '20px');
+ await waitFor(() => {
+ void expect(tabsList.getBoundingClientRect().width).toBe(baselineWidth);
+ });
+ } finally {
+ if (previousValue) {
+ documentRoot.style.setProperty('--spacing', previousValue, previousPriority);
+ } else {
+ documentRoot.style.removeProperty('--spacing');
+ }
+ }
+ },
+};
+
+export const KeyboardFocusAndEscapeCrossShadowRoots: Story = {
+ tags: ['integration'],
+ play: async ({ canvasElement }) => {
+ const ownerDocument = canvasElement.ownerDocument;
+ const componentRoot = await getComponentRoot(canvasElement);
+ const trigger = await getPickerTrigger(canvasElement);
+
+ trigger.focus();
+ void expect(componentRoot.activeElement).toBe(trigger);
+ await userEvent.keyboard('{Enter}');
+
+ const overlayRoot = await getOverlayRoot(ownerDocument);
+ const panel = await getPopoverPanel(overlayRoot);
+
+ await waitFor(() => {
+ const focusedElement = overlayRoot.activeElement;
+ void expect(focusedElement !== null && panel.contains(focusedElement)).toBe(true);
+ });
+
+ await userEvent.keyboard('{Escape}');
+ await waitFor(() => {
+ void expect(overlayRoot.querySelector('[data-slot="popover-content"]')).toBeNull();
+ });
+ await waitFor(() => {
+ void expect(componentRoot.activeElement).toBe(trigger);
+ });
+ },
+};
+
+export const InsideAndOutsideClicksCrossShadowRoots: Story = {
+ tags: ['integration'],
+ render: () => (
+
+
+ Outside control
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ const ownerDocument = canvasElement.ownerDocument;
+ const outsideControl = canvasElement.querySelector(
+ '[data-testid="outside-control"]',
+ );
+ if (!outsideControl) throw new Error('outside control not rendered');
+
+ const { overlayRoot, panel } = await openPicker(canvasElement, ownerDocument);
+ const input = await waitFor(() => {
+ const element = panel.querySelector('input');
+ if (!element) throw new Error('popover search input not rendered');
+ return element;
+ });
+
+ await userEvent.click(input);
+ void expect(overlayRoot.querySelector('[data-slot="popover-content"]')).toBe(panel);
+
+ await userEvent.click(outsideControl);
+ await waitFor(() => {
+ void expect(overlayRoot.querySelector('[data-slot="popover-content"]')).toBeNull();
+ });
+ },
+};
+
+export const AriaControlsDoesNotResolveAcrossSharedShadowRoots: Story = {
+ tags: ['integration'],
+ play: async ({ canvasElement }) => {
+ const ownerDocument = canvasElement.ownerDocument;
+ const { trigger, panel } = await openPicker(canvasElement, ownerDocument);
+
+ void expect(trigger.getAttribute('aria-controls')).toBe(panel.id);
+
+ const reflectedControls = (
+ trigger as HTMLElement & { ariaControlsElements?: readonly Element[] }
+ ).ariaControlsElements;
+ if (!reflectedControls) {
+ throw new Error('Chromium did not expose ariaControlsElements');
+ }
+ // The ID strings match, but IDREF resolution is scoped to the trigger's
+ // shadow tree and therefore cannot reach the separate overlay shadow tree.
+ void expect(reflectedControls).toHaveLength(0);
+ },
+};
+
+export const PopoverDialogSemanticsAndFocusAcrossShadowRoots: Story = {
+ tags: ['integration'],
+ play: async ({ canvasElement }) => {
+ const ownerDocument = canvasElement.ownerDocument;
+ const componentRoot = await getComponentRoot(canvasElement);
+ const trigger = await getPickerTrigger(canvasElement);
+
+ trigger.focus();
+ await userEvent.keyboard('{Enter}');
+
+ const overlayRoot = await getOverlayRoot(ownerDocument);
+ const panel = await getPopoverPanel(overlayRoot);
+
+ void expect(trigger).toHaveAttribute('aria-expanded', 'true');
+ void expect(trigger).toHaveAttribute('aria-haspopup', 'dialog');
+ void expect(panel).toHaveAttribute('role', 'dialog');
+ await waitFor(() => {
+ const focusedElement = overlayRoot.activeElement;
+ void expect(focusedElement !== null && panel.contains(focusedElement)).toBe(true);
+ });
+
+ await userEvent.keyboard('{Escape}');
+ await waitFor(() => {
+ void expect(componentRoot.activeElement).toBe(trigger);
+ });
+ },
+};
+
+export const SharedOverlayReuseAndLifecycle: Story = {
+ tags: ['integration'],
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ const ownerDocument = canvasElement.ownerDocument;
+ const firstTrigger = await getNamedPickerTrigger(canvasElement, 'first-picker');
+ const secondTrigger = await getNamedPickerTrigger(canvasElement, 'second-picker');
+ const overlayRoot = await getOverlayRoot(ownerDocument);
+
+ void expect(ownerDocument.body.querySelectorAll('[data-yv-shadow-overlay-host]')).toHaveLength(
+ 1,
+ );
+
+ await userEvent.click(firstTrigger);
+ await waitFor(() => {
+ void expect(overlayRoot.querySelectorAll('[data-slot="popover-content"]')).toHaveLength(1);
+ });
+ await userEvent.keyboard('{Escape}');
+ await waitFor(() => {
+ void expect(overlayRoot.querySelectorAll('[data-slot="popover-content"]')).toHaveLength(0);
+ });
+
+ await userEvent.click(secondTrigger);
+ await waitFor(() => {
+ void expect(overlayRoot.querySelectorAll('[data-slot="popover-content"]')).toHaveLength(1);
+ });
+
+ await userEvent.click(canvasElement.getElementsByTagName('button')[0]!);
+ await waitFor(() => {
+ void expect(canvasElement.querySelector('[data-testid="second-picker"]')).toBeNull();
+ void expect(overlayRoot.querySelectorAll('[data-slot="popover-content"]')).toHaveLength(0);
+ });
+ void expect(ownerDocument.body.querySelectorAll('[data-yv-shadow-overlay-host]')).toHaveLength(
+ 1,
+ );
+ },
+};
+
+export const MultiplePickersCoordinateInSharedOverlay: Story = {
+ tags: ['integration'],
+ render: () => (
+
+ ),
+ play: async ({ canvasElement }) => {
+ const ownerDocument = canvasElement.ownerDocument;
+ const firstTrigger = await getNamedPickerTrigger(canvasElement, 'first-picker');
+ const secondTrigger = await getNamedPickerTrigger(canvasElement, 'second-picker');
+ const overlayRoot = await getOverlayRoot(ownerDocument);
+
+ await userEvent.click(firstTrigger);
+ await waitFor(() => {
+ void expect(firstTrigger).toHaveAttribute('aria-expanded', 'true');
+ void expect(secondTrigger).toHaveAttribute('aria-expanded', 'false');
+ void expect(overlayRoot.querySelectorAll('[data-slot="popover-content"]')).toHaveLength(1);
+ });
+
+ await userEvent.click(secondTrigger);
+ await waitFor(() => {
+ void expect(firstTrigger).toHaveAttribute('aria-expanded', 'false');
+ void expect(secondTrigger).toHaveAttribute('aria-expanded', 'true');
+ void expect(overlayRoot.querySelectorAll('[data-slot="popover-content"]')).toHaveLength(1);
+ });
+ },
+};
diff --git a/packages/ui/src/components/SignInDialog.shadow-isolation.stories.tsx b/packages/ui/src/components/SignInDialog.shadow-isolation.stories.tsx
new file mode 100644
index 00000000..75d37030
--- /dev/null
+++ b/packages/ui/src/components/SignInDialog.shadow-isolation.stories.tsx
@@ -0,0 +1,105 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { http, HttpResponse } from 'msw';
+import { expect, fn, waitFor } from 'storybook/test';
+import { userEvent } from 'vitest/browser';
+import { ShadowRootHost } from '../lib/shadow-root-host';
+import { globalHandlers } from '../test/mocks/handlers';
+import { SignInDialog } from './sign-in-dialog';
+
+const meta = {
+ title: 'Spikes/SignInDialog Shadow DOM isolation',
+ component: SignInDialog,
+ tags: ['integration'],
+ parameters: {
+ msw: {
+ handlers: [
+ ...globalHandlers,
+ http.get('*/v1/fonts/1/stylesheet', () =>
+ HttpResponse.text('', { headers: { 'Content-Type': 'text/css' } }),
+ ),
+ ],
+ },
+ },
+ args: {
+ open: true,
+ onOpenChange: fn(),
+ appName: 'Example Bible App',
+ onConfirm: fn(),
+ onDecline: fn(),
+ },
+ render: (args) => (
+ <>
+
+ Background control
+
+
+
+
+ >
+ ),
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const UsesSharedOverlayWithFocusAndAccessibleReferences: Story = {
+ play: async ({ canvasElement }) => {
+ const overlayRoot = await waitFor(() => {
+ const root = canvasElement.ownerDocument.body.querySelector(
+ '[data-yv-shadow-overlay-host]',
+ )?.shadowRoot;
+ if (!root) throw new Error('shared overlay shadow root not attached');
+ return root;
+ });
+ const dialog = await waitFor(() => {
+ const element = overlayRoot.querySelector('[role="dialog"]');
+ if (!element) throw new Error('sign-in dialog not rendered in shared overlay');
+ return element;
+ });
+
+ void expect(dialog.getRootNode()).toBe(overlayRoot);
+ void expect(
+ canvasElement.ownerDocument.body.querySelector(':scope > [role="dialog"]'),
+ ).toBeNull();
+
+ const titleId = dialog.getAttribute('aria-labelledby');
+ const descriptionId = dialog.getAttribute('aria-describedby');
+ void expect(titleId).toBeTruthy();
+ void expect(descriptionId).toBeTruthy();
+ void expect(overlayRoot.getElementById(titleId!)).not.toBeNull();
+ void expect(overlayRoot.getElementById(descriptionId!)).not.toBeNull();
+
+ await waitFor(() => {
+ const focusedElement = overlayRoot.activeElement;
+ void expect(focusedElement !== null && dialog.contains(focusedElement)).toBe(true);
+ });
+
+ const hiddenWrapper = canvasElement.ownerDocument.createElement('div');
+ hiddenWrapper.style.display = 'none';
+ const hiddenButton = canvasElement.ownerDocument.createElement('button');
+ hiddenButton.textContent = 'Hidden action';
+ hiddenWrapper.append(hiddenButton);
+ dialog.append(hiddenWrapper);
+
+ const dialogButtons = Array.from(
+ dialog.querySelectorAll('button[data-slot="button"]'),
+ );
+ void expect(dialogButtons).toHaveLength(2);
+ const [confirmButton, declineButton] = dialogButtons;
+ if (!confirmButton || !declineButton) throw new Error('sign-in dialog actions not rendered');
+
+ confirmButton.focus();
+ await userEvent.tab();
+ void expect(overlayRoot.activeElement).toBe(declineButton);
+ await userEvent.tab();
+ await waitFor(() => expect(overlayRoot.activeElement).toBe(confirmButton));
+ await userEvent.tab({ shift: true });
+ await waitFor(() => expect(overlayRoot.activeElement).toBe(declineButton));
+
+ const backgroundControl = canvasElement.querySelector(
+ '[data-testid="background-control"]',
+ );
+ if (!backgroundControl) throw new Error('background control not rendered');
+ void expect(getComputedStyle(backgroundControl).pointerEvents).toBe('none');
+ },
+};
diff --git a/packages/ui/src/components/bible-version-picker.tsx b/packages/ui/src/components/bible-version-picker.tsx
index 80ac412b..3e796608 100644
--- a/packages/ui/src/components/bible-version-picker.tsx
+++ b/packages/ui/src/components/bible-version-picker.tsx
@@ -933,7 +933,7 @@ export function BibleLanguagePickerContent({
value={languageTab}
onValueChange={setLanguageTab}
>
-
+
{t('suggestedTab')}
diff --git a/packages/ui/src/components/ui/dialog.shadow-isolation.test.tsx b/packages/ui/src/components/ui/dialog.shadow-isolation.test.tsx
new file mode 100644
index 00000000..4ff65509
--- /dev/null
+++ b/packages/ui/src/components/ui/dialog.shadow-isolation.test.tsx
@@ -0,0 +1,26 @@
+import { render } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { ShadowRootHost } from '../../lib/shadow-root-host';
+import { Dialog, DialogContent, DialogTitle } from './dialog';
+
+describe('DialogContent shadow isolation', () => {
+ it('portals into the shared shadow overlay when one is available', () => {
+ render(
+
+
+
+ Isolated dialog
+
+
+ ,
+ );
+
+ const overlayRoot = document.body.querySelector(
+ '[data-yv-shadow-overlay-host]',
+ )?.shadowRoot;
+ const dialog = overlayRoot?.querySelector('[role="dialog"]');
+
+ expect(dialog).not.toBeNull();
+ expect(document.body.querySelector(':scope > [role="dialog"]')).toBeNull();
+ });
+});
diff --git a/packages/ui/src/components/ui/dialog.tsx b/packages/ui/src/components/ui/dialog.tsx
index 82c442bf..a8dd8976 100644
--- a/packages/ui/src/components/ui/dialog.tsx
+++ b/packages/ui/src/components/ui/dialog.tsx
@@ -1,6 +1,7 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
+import { useShadowPortalContainer } from '../../lib/shadow-root-host';
import { cn } from '../../lib/utils';
const Dialog = DialogPrimitive.Root;
@@ -11,6 +12,37 @@ type DialogContentProps = React.ComponentProps &
theme?: 'light' | 'dark';
};
+const TABBABLE_SELECTOR = [
+ 'a[href]',
+ 'button:not([disabled])',
+ 'input:not([disabled])',
+ 'select:not([disabled])',
+ 'textarea:not([disabled])',
+ '[tabindex]:not([tabindex="-1"])',
+].join(',');
+
+function keepTabFocusInsideShadowDialog(event: React.KeyboardEvent): void {
+ if (event.defaultPrevented || event.key !== 'Tab') return;
+
+ const root = event.currentTarget.getRootNode();
+ if (!(root instanceof ShadowRoot)) return;
+
+ const tabbableElements = Array.from(
+ event.currentTarget.querySelectorAll(TABBABLE_SELECTOR),
+ ).filter((element) => element.getClientRects().length > 0);
+ const first = tabbableElements[0];
+ const last = tabbableElements[tabbableElements.length - 1];
+ if (!first || !last) return;
+
+ if (event.shiftKey && root.activeElement === first) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && root.activeElement === last) {
+ event.preventDefault();
+ first.focus();
+ }
+}
+
/**
* Shared modal chrome for the SDK's dialogs: renders the portal + overlay and a
* centered card `Content` with the SDK's `data-yv-sdk` scope + theme attributes.
@@ -22,10 +54,13 @@ function DialogContent({
className,
theme = 'light',
children,
+ onKeyDown,
...props
}: DialogContentProps): React.ReactElement {
+ const shadowPortalContainer = useShadowPortalContainer();
+
return (
-
+
{
+ onKeyDown?.(event);
+ keepTabFocusInsideShadowDialog(event);
+ }}
className={cn(
'yv:fixed yv:left-1/2 yv:top-1/2 yv:z-50 yv:-translate-x-1/2 yv:-translate-y-1/2',
'yv:w-[calc(100vw-2rem)] yv:max-w-sm',
diff --git a/packages/ui/src/components/ui/popover.tsx b/packages/ui/src/components/ui/popover.tsx
index bfe3817b..0c3ee763 100644
--- a/packages/ui/src/components/ui/popover.tsx
+++ b/packages/ui/src/components/ui/popover.tsx
@@ -2,6 +2,7 @@ import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
+import { useShadowPortalContainer } from '@/lib/shadow-root-host';
import { Button } from './button';
import { XIcon } from '../icons/x';
@@ -38,9 +39,10 @@ function PopoverContent({
theme?: 'light' | 'dark';
}): React.ReactNode {
const { t } = useTranslation(undefined, { i18n });
+ const shadowPortalContainer = useShadowPortalContainer();
return (
-
+
{
+ it('renders popover content in the document without creating an overlay host', () => {
+ const overlayHostCount = document.body.querySelectorAll('[data-yv-shadow-overlay-host]').length;
+
+ render(
+
+ Open
+ Content
+ ,
+ );
+
+ const content = document.body.querySelector('[data-slot="popover-content"]');
+ expect(content?.getRootNode()).toBe(document);
+ expect(document.body.querySelectorAll('[data-yv-shadow-overlay-host]')).toHaveLength(
+ overlayHostCount,
+ );
+ });
+
+ it('renders dialog content in the document without creating an overlay host', () => {
+ const overlayHostCount = document.body.querySelectorAll('[data-yv-shadow-overlay-host]').length;
+
+ render(
+
+
+ Title
+ Description
+
+ ,
+ );
+
+ const content = document.body.querySelector('[role="dialog"]');
+ expect(content?.getRootNode()).toBe(document);
+ expect(document.body.querySelectorAll('[data-yv-shadow-overlay-host]')).toHaveLength(
+ overlayHostCount,
+ );
+ });
+});
diff --git a/packages/ui/src/lib/shadow-root-host.test.tsx b/packages/ui/src/lib/shadow-root-host.test.tsx
index 514234e2..38f58c81 100644
--- a/packages/ui/src/lib/shadow-root-host.test.tsx
+++ b/packages/ui/src/lib/shadow-root-host.test.tsx
@@ -60,4 +60,37 @@ describe('ShadowRootHost', () => {
expect(style?.getAttribute('data-href')).toBe('yv-sdk-shadow-styles');
expect(style?.getAttribute('data-precedence')).toBe('yv-sdk');
});
+
+ it('reuses one document-level overlay root and keeps it available after unmount', () => {
+ const { unmount } = render(
+ <>
+
+ first
+
+
+ second
+
+ >,
+ );
+
+ const overlayHosts = document.body.querySelectorAll(
+ '[data-yv-shadow-overlay-host]',
+ );
+ expect(overlayHosts).toHaveLength(1);
+
+ const overlayRoot = overlayHosts[0]?.shadowRoot;
+ expect(overlayRoot).not.toBeNull();
+
+ // jsdom does not implement constructable stylesheets, so the shared
+ // overlay root installs the direct