Skip to content

Commit e486c5c

Browse files
authored
[popups] Fix outside press dismissal in shared shadow root (#4333)
1 parent d8c529c commit e486c5c

File tree

4 files changed

+260
-6
lines changed

4 files changed

+260
-6
lines changed

packages/react/src/dialog/root/DialogRoot.test.tsx

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,107 @@ describe('<Dialog.Root />', () => {
324324
});
325325
expect(handleOpenChange.callCount).to.equal(1);
326326
});
327+
328+
it('closing via outside press: works when clicking another element inside the same shadow root', async () => {
329+
const handleOpenChange = spy();
330+
331+
const host = document.body.appendChild(document.createElement('div'));
332+
const shadowRoot = host.attachShadow({ mode: 'open' });
333+
const container = document.createElement('div');
334+
shadowRoot.appendChild(container);
335+
336+
try {
337+
await render(
338+
<React.Fragment>
339+
<button data-testid="outside">Outside</button>
340+
<TestDialog
341+
rootProps={{ defaultOpen: true, onOpenChange: handleOpenChange, modal: false }}
342+
portalProps={{ container: shadowRoot }}
343+
/>
344+
</React.Fragment>,
345+
{ container },
346+
);
347+
348+
const outsideButton = shadowRoot.querySelector('[data-testid="outside"]') as HTMLElement;
349+
350+
fireEvent.click(outsideButton);
351+
352+
await waitFor(() => {
353+
expect(shadowRoot.querySelector('[role="dialog"]')).to.equal(null);
354+
});
355+
356+
expect(handleOpenChange.callCount).to.equal(1);
357+
expect(handleOpenChange.firstCall.args[1].reason).to.equal(REASONS.outsidePress);
358+
} finally {
359+
await act(async () => {
360+
host.remove();
361+
});
362+
}
363+
});
364+
365+
it('closing via outside press: works when clicking outside the shadow root', async () => {
366+
const handleOpenChange = spy();
367+
368+
const host = document.body.appendChild(document.createElement('div'));
369+
const shadowRoot = host.attachShadow({ mode: 'open' });
370+
const container = document.createElement('div');
371+
shadowRoot.appendChild(container);
372+
373+
try {
374+
await render(
375+
<TestDialog
376+
rootProps={{ defaultOpen: true, onOpenChange: handleOpenChange, modal: false }}
377+
portalProps={{ container: shadowRoot }}
378+
/>,
379+
{ container },
380+
);
381+
382+
fireEvent.click(document.body);
383+
384+
await waitFor(() => {
385+
expect(shadowRoot.querySelector('[role="dialog"]')).to.equal(null);
386+
});
387+
388+
expect(handleOpenChange.callCount).to.equal(1);
389+
expect(handleOpenChange.firstCall.args[1].reason).to.equal(REASONS.outsidePress);
390+
} finally {
391+
await act(async () => {
392+
host.remove();
393+
});
394+
}
395+
});
396+
397+
it('closing via outside press: works for a modal dialog when clicking outside the shadow root', async () => {
398+
const handleOpenChange = spy();
399+
400+
const host = document.body.appendChild(document.createElement('div'));
401+
const shadowRoot = host.attachShadow({ mode: 'open' });
402+
const container = document.createElement('div');
403+
shadowRoot.appendChild(container);
404+
405+
try {
406+
await render(
407+
<TestDialog
408+
rootProps={{ defaultOpen: true, onOpenChange: handleOpenChange, modal: true }}
409+
portalProps={{ container: shadowRoot }}
410+
/>,
411+
{ container },
412+
);
413+
414+
fireEvent.click(document.body);
415+
416+
await waitFor(() => {
417+
expect(shadowRoot.querySelector('[role="dialog"]')).to.equal(null);
418+
});
419+
420+
expect(handleOpenChange.callCount).to.equal(1);
421+
expect(handleOpenChange.firstCall.args[1].reason).to.equal(REASONS.outsidePress);
422+
} finally {
423+
await act(async () => {
424+
host.remove();
425+
});
426+
}
427+
});
327428
});
328429

329430
it.skipIf(isJSDOM)('waits for the exit transition to finish before unmounting', async () => {

packages/react/src/floating-ui-react/hooks/useDismiss.test.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,89 @@ describe.skipIf(!isJSDOM)('useDismiss', () => {
169169
thirdParty.remove();
170170
});
171171

172+
test('dismisses when clicking outside a shared shadow root', async () => {
173+
function App({ shadowRoot }: { shadowRoot: ShadowRoot }) {
174+
const [isOpen, setIsOpen] = React.useState(true);
175+
176+
const { context, refs } = useFloating({
177+
open: isOpen,
178+
onOpenChange: setIsOpen,
179+
});
180+
181+
const dismiss = useDismiss(context);
182+
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]);
183+
184+
return (
185+
<React.Fragment>
186+
<button {...getReferenceProps({ ref: refs.setReference })} />
187+
{isOpen && (
188+
<FloatingPortal container={shadowRoot}>
189+
<div role="dialog" {...getFloatingProps({ ref: refs.setFloating })} />
190+
</FloatingPortal>
191+
)}
192+
</React.Fragment>
193+
);
194+
}
195+
196+
const host = document.body.appendChild(document.createElement('div'));
197+
const shadowRoot = host.attachShadow({ mode: 'open' });
198+
const container = document.createElement('div');
199+
shadowRoot.appendChild(container);
200+
201+
try {
202+
render(<App shadowRoot={shadowRoot} />, { container });
203+
204+
await userEvent.click(document.body);
205+
206+
expect(shadowRoot.querySelector('[role="dialog"]')).toBe(null);
207+
} finally {
208+
host.remove();
209+
}
210+
});
211+
212+
test('dismisses when clicking outside a shared shadow root while focus is managed', async () => {
213+
function App({ shadowRoot }: { shadowRoot: ShadowRoot }) {
214+
const [isOpen, setIsOpen] = React.useState(true);
215+
216+
const { context, refs } = useFloating({
217+
open: isOpen,
218+
onOpenChange: setIsOpen,
219+
});
220+
221+
const dismiss = useDismiss(context);
222+
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]);
223+
224+
return (
225+
<React.Fragment>
226+
<button {...getReferenceProps({ ref: refs.setReference })} />
227+
{isOpen && (
228+
<FloatingPortal container={shadowRoot}>
229+
<FloatingFocusManager context={context}>
230+
<div role="dialog" {...getFloatingProps({ ref: refs.setFloating })} />
231+
</FloatingFocusManager>
232+
</FloatingPortal>
233+
)}
234+
</React.Fragment>
235+
);
236+
}
237+
238+
const host = document.body.appendChild(document.createElement('div'));
239+
const shadowRoot = host.attachShadow({ mode: 'open' });
240+
const container = document.createElement('div');
241+
shadowRoot.appendChild(container);
242+
243+
try {
244+
render(<App shadowRoot={shadowRoot} />, { container });
245+
await flushMicrotasks();
246+
247+
await userEvent.click(document.body);
248+
249+
expect(shadowRoot.querySelector('[role="dialog"]')).toBe(null);
250+
} finally {
251+
host.remove();
252+
}
253+
});
254+
172255
test('outsidePress not ignored for nested floating elements', async () => {
173256
function Popover({
174257
children,

packages/react/src/floating-ui-react/hooks/useDismiss.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -333,13 +333,13 @@ export function useDismiss(
333333

334334
const target = getTarget(event);
335335
const inertSelector = `[${createAttribute('inert')}]`;
336-
let markers = Array.from(
337-
ownerDocument(store.select('floatingElement')).querySelectorAll(inertSelector),
338-
);
339336
const targetRoot = isElement(target) ? target.getRootNode() : null;
340-
if (isShadowRoot(targetRoot)) {
341-
markers = markers.concat(Array.from(targetRoot.querySelectorAll(inertSelector)));
342-
}
337+
const markers = Array.from(
338+
(isShadowRoot(targetRoot)
339+
? targetRoot
340+
: ownerDocument(store.select('floatingElement'))
341+
).querySelectorAll(inertSelector),
342+
);
343343

344344
const triggers = store.context.triggerElements;
345345

packages/react/src/popover/root/PopoverRoot.test.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { spy } from 'sinon';
99
import { createRenderer, isJSDOM, popupConformanceTests, wait } from '#test-utils';
1010
import { OPEN_DELAY } from '../utils/constants';
1111
import { PATIENT_CLICK_THRESHOLD } from '../../utils/constants';
12+
import { REASONS } from '../../utils/reasons';
1213

1314
describe('<Popover.Root />', () => {
1415
beforeEach(() => {
@@ -768,6 +769,75 @@ describe('<Popover.Root />', () => {
768769
});
769770
expect(handleOpenChange.callCount).to.equal(1);
770771
});
772+
773+
it('closing via outside press: works when clicking another element inside the same shadow root', async () => {
774+
const handleOpenChange = spy();
775+
776+
const host = document.body.appendChild(document.createElement('div'));
777+
const shadowRoot = host.attachShadow({ mode: 'open' });
778+
const container = document.createElement('div');
779+
shadowRoot.appendChild(container);
780+
781+
try {
782+
await render(
783+
<React.Fragment>
784+
<button data-testid="outside">Outside</button>
785+
<TestPopover
786+
rootProps={{ defaultOpen: true, onOpenChange: handleOpenChange }}
787+
portalProps={{ container: shadowRoot }}
788+
/>
789+
</React.Fragment>,
790+
{ container },
791+
);
792+
793+
const outsideButton = shadowRoot.querySelector('[data-testid="outside"]') as HTMLElement;
794+
795+
fireEvent.click(outsideButton);
796+
797+
await waitFor(() => {
798+
expect(shadowRoot.querySelector('[role="dialog"]')).to.equal(null);
799+
});
800+
801+
expect(handleOpenChange.callCount).to.equal(1);
802+
expect(handleOpenChange.firstCall.args[1].reason).to.equal(REASONS.outsidePress);
803+
} finally {
804+
await act(async () => {
805+
host.remove();
806+
});
807+
}
808+
});
809+
810+
it('closing via outside press: works when clicking outside the shadow root', async () => {
811+
const handleOpenChange = spy();
812+
813+
const host = document.body.appendChild(document.createElement('div'));
814+
const shadowRoot = host.attachShadow({ mode: 'open' });
815+
const container = document.createElement('div');
816+
shadowRoot.appendChild(container);
817+
818+
try {
819+
await render(
820+
<TestPopover
821+
rootProps={{ defaultOpen: true, onOpenChange: handleOpenChange }}
822+
portalProps={{ container: shadowRoot }}
823+
/>,
824+
{ container },
825+
);
826+
827+
fireEvent.click(document.body);
828+
829+
await waitFor(() => {
830+
expect(shadowRoot.querySelector('[role="dialog"]')).to.equal(null);
831+
});
832+
833+
expect(handleOpenChange.callCount).to.equal(1);
834+
expect(handleOpenChange.firstCall.args[1].reason).to.equal(REASONS.outsidePress);
835+
} finally {
836+
await act(async () => {
837+
host.remove();
838+
});
839+
}
840+
});
771841
});
772842

773843
describe('non-modal focus transitions', () => {

0 commit comments

Comments
 (0)