diff --git a/src/popover/__tests__/popover.test.tsx b/src/popover/__tests__/popover.test.tsx index 839072bb62..fb6b5c8213 100644 --- a/src/popover/__tests__/popover.test.tsx +++ b/src/popover/__tests__/popover.test.tsx @@ -42,6 +42,12 @@ class PopoverInternalWrapper extends PopoverWrapper { } return this.findByClassName(styles.body); } + findContainer({ renderWithPortal } = { renderWithPortal: false }): ElementWrapper | null { + if (renderWithPortal) { + return createWrapper().findByClassName(styles.container); + } + return this.findByClassName(styles.container); + } } function renderPopover(props: PopoverProps & { ref?: React.Ref }) { @@ -415,3 +421,90 @@ test('does not add portal to the body unless visible', () => { expect(document.querySelectorAll('body > div')).toHaveLength(1); }); + +describe('inside shadow DOM', () => { + // React below v17 does not dispatch events to components inside Shadow DOM, so to make clicking + // on a Popover trigger work inside Shadow DOM we need to catch the click event on the topmost + // node inside Shadow DOM, look back through all the nodes it bubbled through, find React component + // instances corresponding to those nodes, and manually call onClick on them. See + // https://github.com/facebook/react/issues/10422#issuecomment-674928774 + function retargetClicks(root: HTMLElement) { + function findReactComponent(item: EventTarget) { + for (const key in item) { + if (Object.prototype.hasOwnProperty.call(item, key) && key.indexOf('_reactInternal') !== -1) { + return item[key as keyof typeof item] as any; + } + } + } + + root.addEventListener('click', event => { + const eventPath = event.composedPath(); + for (const i in eventPath) { + const item = eventPath[i]; + + const internalComponent = findReactComponent(item); + if (internalComponent && internalComponent.memoizedProps) { + internalComponent.memoizedProps.onClick?.(event); + break; + } + + if (item === root) { + break; + } + } + }); + } + + function renderPopoverInShadowDom(props: PopoverProps & { ref?: React.Ref }) { + const shadowContainer = document.createElement('div'); + shadowContainer.setAttribute('id', 'shadow-container'); + shadowContainer.attachShadow({ mode: 'open' }); + + const shadowChild = document.createElement('div'); + shadowChild.setAttribute('id', 'shadow-child'); + shadowContainer.shadowRoot!.appendChild(shadowChild); + + document.body.appendChild(shadowContainer); + + retargetClicks(shadowChild); + + const { container } = render(, { container: shadowChild }); + + return new PopoverInternalWrapper(container); + } + + afterEach(() => { + // cleanup test container after each test + document.getElementById('shadow-container')?.remove(); + }); + + it('renders text trigger correctly', () => { + const wrapper = renderPopoverInShadowDom({ children: 'Trigger', triggerAriaLabel: 'Test aria label' }); + expect(wrapper.findTrigger().getElement().tagName).toBe('BUTTON'); + expect(wrapper.findTrigger().getElement()).toHaveTextContent('Trigger'); + expect(wrapper.findTrigger().getElement()).toHaveAccessibleName('Test aria label'); + }); + + it('renders the popover header correctly', () => { + const wrapper = renderPopoverInShadowDom({ children: 'Trigger', header: 'Memory error' }); + wrapper.findTrigger().click(); + + expect(wrapper.findHeader()!.getElement()).toHaveTextContent('Memory error'); + // this assertion fails if Popover bails early on calculating its position + expect(wrapper.findContainer()!.getElement()).toHaveAttribute( + 'style', + 'inset-block-start: 0; inset-inline-start: 13px;' + ); + }); + + it('renders the popover header correctly with portal', () => { + const wrapper = renderPopoverInShadowDom({ children: 'Trigger', header: 'Memory error', renderWithPortal: true }); + wrapper.findTrigger().click(); + expect(wrapper.findHeader({ renderWithPortal: true })!.getElement()).toHaveTextContent('Memory error'); + // this assertion fails if Popover bails early on calculating its position + expect(wrapper.findContainer({ renderWithPortal: true })!.getElement()).toHaveAttribute( + 'style', + 'z-index: 7000; inset-block-start: 18px; inset-inline-start: 10px;' + ); + }); +}); diff --git a/src/popover/use-popover-position.ts b/src/popover/use-popover-position.ts index 5b49c316ad..0d653daeee 100644 --- a/src/popover/use-popover-position.ts +++ b/src/popover/use-popover-position.ts @@ -63,10 +63,14 @@ export default function usePopoverPosition({ const document = popover.ownerDocument; const track = trackRef.current; + const rootNode = popover.getRootNode(); + const isInShadowDom = rootNode !== document && rootNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE; + // If the popover body isn't being rendered for whatever reason (e.g. "display: none" or JSDOM), // or track does not belong to the document - bail on calculating dimensions. const { offsetWidth, offsetHeight } = getOffsetDimensions(popover); - if (offsetWidth === 0 || offsetHeight === 0 || !nodeContains(document.body, track)) { + + if (offsetWidth === 0 || offsetHeight === 0 || !nodeContains(isInShadowDom ? rootNode : document.body, track)) { return; }