diff --git a/packages/core/src/dom/ui/popover/popover.ts b/packages/core/src/dom/ui/popover/popover.ts index db1bce26..0dc6fe64 100644 --- a/packages/core/src/dom/ui/popover/popover.ts +++ b/packages/core/src/dom/ui/popover/popover.ts @@ -146,10 +146,12 @@ export function createPopover(options: PopoverOptions): PopoverApi { function handleDocumentPointerdown(event: PointerEvent): void { if (!closeOnOutsideClick() || !state.current.active) return; - const target = event.target as Node | null; - if (!target) return; + // Use composedPath so the check works when the popup lives inside a + // Shadow DOM tree. event.target is retargeted to the shadow host when + // the listener is on document, so contains() would always fail. + const path = event.composedPath(); - if (triggerEl?.contains(target) || popupEl?.contains(target)) return; + if ((triggerEl && path.includes(triggerEl)) || (popupEl && path.includes(popupEl))) return; applyClose('outside-click', event); } diff --git a/packages/core/src/dom/ui/popover/tests/popover.test.ts b/packages/core/src/dom/ui/popover/tests/popover.test.ts index f84cd92e..d3f9e52a 100644 --- a/packages/core/src/dom/ui/popover/tests/popover.test.ts +++ b/packages/core/src/dom/ui/popover/tests/popover.test.ts @@ -148,6 +148,92 @@ describe('createPopover', () => { }); }); + describe('outside-click', () => { + it('does not close when clicking inside the popup', () => { + const { popover, onOpenChange } = createTestPopover(); + const popup = document.createElement('div'); + const child = document.createElement('button'); + popup.appendChild(child); + document.body.appendChild(popup); + + popover.setPopupElement(popup); + popover.open(); + flush(); + onOpenChange.mockClear(); + + child.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true })); + + expect(onOpenChange).not.toHaveBeenCalled(); + + popover.destroy(); + popup.remove(); + }); + + it('closes when clicking outside the popup', () => { + const { popover, onOpenChange } = createTestPopover(); + const popup = document.createElement('div'); + const outside = document.createElement('div'); + document.body.appendChild(popup); + document.body.appendChild(outside); + + popover.setPopupElement(popup); + popover.open(); + flush(); + onOpenChange.mockClear(); + + outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true })); + + expect(onOpenChange).toHaveBeenCalledWith(false, expect.objectContaining({ reason: 'outside-click' })); + + popover.destroy(); + popup.remove(); + outside.remove(); + }); + + it('does not close when clicking inside the trigger', () => { + const { popover, onOpenChange } = createTestPopover(); + const trigger = document.createElement('button'); + document.body.appendChild(trigger); + + popover.setTriggerElement(trigger); + popover.open(); + flush(); + onOpenChange.mockClear(); + + trigger.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true })); + + expect(onOpenChange).not.toHaveBeenCalledWith(false, expect.anything()); + + popover.destroy(); + trigger.remove(); + }); + + it('uses composedPath to detect clicks inside a Shadow DOM popup', () => { + const { popover, onOpenChange } = createTestPopover(); + const host = document.createElement('div'); + const shadow = host.attachShadow({ mode: 'open' }); + const popup = document.createElement('div'); + const child = document.createElement('button'); + popup.appendChild(child); + shadow.appendChild(popup); + document.body.appendChild(host); + + popover.setPopupElement(popup); + popover.open(); + flush(); + onOpenChange.mockClear(); + + // Clicking the child inside the shadow tree — event.target at + // document level is the shadow host, but composedPath includes popup. + child.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true })); + + expect(onOpenChange).not.toHaveBeenCalled(); + + popover.destroy(); + host.remove(); + }); + }); + describe('destroy', () => { it('prevents further open/close calls', () => { const { popover, onOpenChange } = createTestPopover();