From f569abfb16a9ae8899379ee5721ccfa359573e26 Mon Sep 17 00:00:00 2001 From: abharms Date: Wed, 19 Aug 2026 13:11:07 -0500 Subject: [PATCH 1/3] feat(ui): prototype shared shadow overlay for portals --- .changeset/calm-shadows-float.md | 5 + ...05-prototype-shadow-dom-style-isolation.md | 34 +- ...VersionPicker.shadow-isolation.stories.tsx | 486 ++++++++++++++++++ .../SignInDialog.shadow-isolation.stories.tsx | 71 +++ .../src/components/bible-version-picker.tsx | 2 +- .../ui/dialog.shadow-isolation.test.tsx | 26 + packages/ui/src/components/ui/dialog.tsx | 5 +- packages/ui/src/components/ui/popover.tsx | 6 +- .../components/ui/portal-fallback.test.tsx | 42 ++ packages/ui/src/lib/shadow-root-host.test.tsx | 33 ++ packages/ui/src/lib/shadow-root-host.tsx | 37 +- 11 files changed, 738 insertions(+), 9 deletions(-) create mode 100644 .changeset/calm-shadows-float.md create mode 100644 packages/ui/src/components/BibleVersionPicker.shadow-isolation.stories.tsx create mode 100644 packages/ui/src/components/SignInDialog.shadow-isolation.stories.tsx create mode 100644 packages/ui/src/components/ui/dialog.shadow-isolation.test.tsx create mode 100644 packages/ui/src/components/ui/portal-fallback.test.tsx 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} + +
+ ); +} + +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: () => ( +
+ + +
+ ), + 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: () => ( +
+ + +
+ ), + 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..a128556a --- /dev/null +++ b/packages/ui/src/components/SignInDialog.shadow-isolation.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { http, HttpResponse } from 'msw'; +import { expect, fn, waitFor } from 'storybook/test'; +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) => ( + + + + ), +} 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); + }); + }, +}; 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..f00a0d72 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; @@ -24,8 +25,10 @@ function DialogContent({ children, ...props }: DialogContentProps): React.ReactElement { + 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