fix(core): use composedPath for popover outside-click detection (#806)

This commit is contained in:
rahim
2026-03-09 21:33:34 -07:00
committed by GitHub
parent bd4e47b783
commit 747d15910b
2 changed files with 91 additions and 3 deletions
+5 -3
View File
@@ -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);
}
@@ -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();