import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type {ReactNode} from 'react';
import {useState} from 'react';
import {
FloatingFocusManager,
FloatingNode,
FloatingPortal,
FloatingTree,
useDismiss,
useFloating,
useFloatingNodeId,
useFloatingParentNodeId,
useFocus,
useInteractions,
} from '../../src';
import type {UseDismissProps} from '../../src/hooks/useDismiss';
import {normalizeProp} from '../../src/hooks/useDismiss';
function App(props: UseDismissProps) {
const [open, setOpen] = useState(true);
const {refs, context} = useFloating({
open,
onOpenChange(open, _, reason) {
setOpen(open);
if (props.outsidePress) {
expect(reason).toBe('outside-press');
} else if (props.escapeKey) {
expect(reason).toBe('escape-key');
} else if (props.referencePress) {
expect(reason).toBe('reference-press');
} else if (props.ancestorScroll) {
expect(reason).toBe('ancestor-scroll');
}
},
});
const {getReferenceProps, getFloatingProps} = useInteractions([
useDismiss(context, props),
]);
return (
<>
{open && (
)}
>
);
}
describe('true', () => {
test('dismisses with escape key', () => {
render();
fireEvent.keyDown(document.body, {key: 'Escape'});
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
cleanup();
});
test('dismisses with outside pointer press', async () => {
render();
await userEvent.click(document.body);
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
cleanup();
});
test('dismisses with reference press', async () => {
render();
await userEvent.click(screen.getByRole('button'));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
cleanup();
});
test('dismisses with ancestor scroll', async () => {
render();
fireEvent.scroll(window);
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
cleanup();
});
test('outsidePress function guard', async () => {
render( false} />);
await userEvent.click(document.body);
expect(screen.queryByRole('tooltip')).toBeInTheDocument();
cleanup();
});
test('outsidePress ignored for third party elements', async () => {
function App() {
const [isOpen, setIsOpen] = useState(true);
const {context, refs} = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
});
const dismiss = useDismiss(context);
const {getReferenceProps, getFloatingProps} = useInteractions([dismiss]);
return (
<>
{isOpen && (
)}
>
);
}
render();
await act(async () => {});
const thirdParty = document.createElement('div');
thirdParty.setAttribute('data-testid', 'third-party');
document.body.append(thirdParty);
await userEvent.click(thirdParty);
expect(screen.queryByRole('dialog')).toBeInTheDocument();
thirdParty.remove();
});
test('outsidePress not ignored for nested floating elements', async () => {
function Popover({
children,
id,
modal,
}: {
children?: ReactNode;
id: string;
modal?: boolean | null;
}) {
const [isOpen, setIsOpen] = useState(true);
const {context, refs} = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
});
const dismiss = useDismiss(context);
const {getReferenceProps, getFloatingProps} = useInteractions([dismiss]);
const dialogJsx = (
{children}
);
return (
<>
{isOpen && (
<>
{modal == null ? (
dialogJsx
) : (
{dialogJsx}
)}
>
)}
>
);
}
function App({modal}: {modal: [boolean, boolean] | null}) {
return (
);
}
const {unmount} = render();
await act(async () => {});
let popover1 = screen.getByTestId('popover-1');
let popover2 = screen.getByTestId('popover-2');
await userEvent.click(popover2);
expect(popover1).toBeInTheDocument();
expect(popover2).toBeInTheDocument();
await userEvent.click(popover1);
expect(popover2).not.toBeInTheDocument();
unmount();
const {unmount: unmount2} = render();
await act(async () => {});
popover1 = screen.getByTestId('popover-1');
popover2 = screen.getByTestId('popover-2');
await userEvent.click(popover2);
expect(popover1).toBeInTheDocument();
expect(popover2).toBeInTheDocument();
await userEvent.click(popover1);
expect(popover2).not.toBeInTheDocument();
unmount2();
const {unmount: unmount3} = render();
await act(async () => {});
popover1 = screen.getByTestId('popover-1');
popover2 = screen.getByTestId('popover-2');
await userEvent.click(popover2);
expect(popover1).toBeInTheDocument();
expect(popover2).toBeInTheDocument();
await userEvent.click(popover1);
expect(popover2).not.toBeInTheDocument();
unmount3();
render();
await act(async () => {});
popover1 = screen.getByTestId('popover-1');
popover2 = screen.getByTestId('popover-2');
await userEvent.click(popover2);
expect(popover1).toBeInTheDocument();
expect(popover2).toBeInTheDocument();
await userEvent.click(popover1);
expect(popover2).not.toBeInTheDocument();
});
});
describe('false', () => {
test('dismisses with escape key', () => {
render();
fireEvent.keyDown(document.body, {key: 'Escape'});
expect(screen.queryByRole('tooltip')).toBeInTheDocument();
cleanup();
});
test('dismisses with outside press', async () => {
render();
await userEvent.click(document.body);
expect(screen.queryByRole('tooltip')).toBeInTheDocument();
cleanup();
});
test('dismisses with reference pointer down', async () => {
render();
await userEvent.click(screen.getByRole('button'));
expect(screen.queryByRole('tooltip')).toBeInTheDocument();
cleanup();
});
test('dismisses with ancestor scroll', async () => {
render();
fireEvent.scroll(window);
expect(screen.queryByRole('tooltip')).toBeInTheDocument();
cleanup();
});
test('does not dismiss when clicking portaled children', async () => {
function App() {
const [open, setOpen] = useState(true);
const {refs, context} = useFloating({
open,
onOpenChange: setOpen,
});
const {getReferenceProps, getFloatingProps} = useInteractions([
useDismiss(context),
]);
return (
<>
{open && (
)}
>
);
}
render();
fireEvent.pointerDown(screen.getByTestId('portaled-button'), {
bubbles: true,
});
expect(screen.queryByTestId('portaled-button')).toBeInTheDocument();
cleanup();
});
test('outsidePress function guard', async () => {
render( true} />);
await userEvent.click(document.body);
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
cleanup();
});
});
describe('bubbles', () => {
const Dialog = ({
testId,
children,
...props
}: UseDismissProps & {testId: string; children: ReactNode}) => {
const [open, setOpen] = useState(true);
const nodeId = useFloatingNodeId();
const {refs, context} = useFloating({
open,
onOpenChange: setOpen,
nodeId,
});
const {getReferenceProps, getFloatingProps} = useInteractions([
useDismiss(context, props),
]);
return (
{open && (
{children}
)}
);
};
const NestedDialog = (
props: UseDismissProps & {testId: string; children: ReactNode},
) => {
const parentId = useFloatingParentNodeId();
if (parentId == null) {
return (
);
}
return ;
};
describe('prop resolution', () => {
test('undefined', () => {
const {escapeKey: escapeKeyBubbles, outsidePress: outsidePressBubbles} =
normalizeProp();
expect(escapeKeyBubbles).toBe(false);
expect(outsidePressBubbles).toBe(true);
});
test('false', () => {
const {escapeKey: escapeKeyBubbles, outsidePress: outsidePressBubbles} =
normalizeProp(false);
expect(escapeKeyBubbles).toBe(false);
expect(outsidePressBubbles).toBe(false);
});
test('{}', () => {
const {escapeKey: escapeKeyBubbles, outsidePress: outsidePressBubbles} =
normalizeProp({});
expect(escapeKeyBubbles).toBe(false);
expect(outsidePressBubbles).toBe(true);
});
test('{ escapeKey: false }', () => {
const {escapeKey: escapeKeyBubbles, outsidePress: outsidePressBubbles} =
normalizeProp({
escapeKey: false,
});
expect(escapeKeyBubbles).toBe(false);
expect(outsidePressBubbles).toBe(true);
});
test('{ outsidePress: false }', () => {
const {escapeKey: escapeKeyBubbles, outsidePress: outsidePressBubbles} =
normalizeProp({
outsidePress: false,
});
expect(escapeKeyBubbles).toBe(false);
expect(outsidePressBubbles).toBe(false);
});
});
describe('outsidePress', () => {
test('true', async () => {
render(
,
);
expect(screen.queryByTestId('outer')).toBeInTheDocument();
expect(screen.queryByTestId('inner')).toBeInTheDocument();
fireEvent.pointerDown(document.body);
expect(screen.queryByTestId('outer')).not.toBeInTheDocument();
expect(screen.queryByTestId('inner')).not.toBeInTheDocument();
cleanup();
});
test('false', async () => {
render(
,
);
expect(screen.queryByTestId('outer')).toBeInTheDocument();
expect(screen.queryByTestId('inner')).toBeInTheDocument();
fireEvent.pointerDown(document.body);
expect(screen.queryByTestId('outer')).toBeInTheDocument();
expect(screen.queryByTestId('inner')).not.toBeInTheDocument();
fireEvent.pointerDown(document.body);
expect(screen.queryByTestId('outer')).not.toBeInTheDocument();
expect(screen.queryByTestId('inner')).not.toBeInTheDocument();
cleanup();
});
test('mixed', async () => {
render(
,
);
expect(screen.queryByTestId('outer')).toBeInTheDocument();
expect(screen.queryByTestId('inner')).toBeInTheDocument();
fireEvent.pointerDown(document.body);
expect(screen.queryByTestId('outer')).toBeInTheDocument();
expect(screen.queryByTestId('inner')).not.toBeInTheDocument();
fireEvent.pointerDown(document.body);
expect(screen.queryByTestId('outer')).not.toBeInTheDocument();
expect(screen.queryByTestId('inner')).not.toBeInTheDocument();
cleanup();
});
});
describe('escapeKey', () => {
test('without FloatingTree', async () => {
function App() {
const [popoverOpen, setPopoverOpen] = useState(true);
const [tooltipOpen, setTooltipOpen] = useState(false);
const popover = useFloating({
open: popoverOpen,
onOpenChange: setPopoverOpen,
});
const tooltip = useFloating({
open: tooltipOpen,
onOpenChange: setTooltipOpen,
});
const popoverInteractions = useInteractions([
useDismiss(popover.context),
]);
const tooltipInteractions = useInteractions([
useFocus(tooltip.context, {visibleOnly: false}),
useDismiss(tooltip.context),
]);
return (
<>
{popoverOpen && (
)}
{tooltipOpen && (
)}
>
);
}
render();
act(() => screen.getByTestId('focus-button').focus());
expect(screen.queryByRole('tooltip')).toBeInTheDocument();
await userEvent.keyboard('{Escape}');
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).toBeInTheDocument();
cleanup();
});
test('true', async () => {
render(
,
);
expect(screen.queryByTestId('outer')).toBeInTheDocument();
expect(screen.queryByTestId('inner')).toBeInTheDocument();
await userEvent.keyboard('{Escape}');
expect(screen.queryByTestId('outer')).not.toBeInTheDocument();
expect(screen.queryByTestId('inner')).not.toBeInTheDocument();
cleanup();
});
test('false', async () => {
render(
,
);
expect(screen.queryByTestId('outer')).toBeInTheDocument();
expect(screen.queryByTestId('inner')).toBeInTheDocument();
await userEvent.keyboard('{Escape}');
expect(screen.queryByTestId('outer')).toBeInTheDocument();
expect(screen.queryByTestId('inner')).not.toBeInTheDocument();
await userEvent.keyboard('{Escape}');
expect(screen.queryByTestId('outer')).not.toBeInTheDocument();
expect(screen.queryByTestId('inner')).not.toBeInTheDocument();
cleanup();
});
test('mixed', async () => {
render(
,
);
expect(screen.queryByTestId('outer')).toBeInTheDocument();
expect(screen.queryByTestId('inner')).toBeInTheDocument();
await userEvent.keyboard('{Escape}');
expect(screen.queryByTestId('outer')).toBeInTheDocument();
expect(screen.queryByTestId('inner')).not.toBeInTheDocument();
await userEvent.keyboard('{Escape}');
expect(screen.queryByTestId('outer')).not.toBeInTheDocument();
expect(screen.queryByTestId('inner')).not.toBeInTheDocument();
cleanup();
});
});
});
describe('capture', () => {
describe('prop resolution', () => {
test('undefined', () => {
const {escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture} =
normalizeProp();
expect(escapeKeyCapture).toBe(false);
expect(outsidePressCapture).toBe(true);
});
test('{}', () => {
const {escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture} =
normalizeProp({});
expect(escapeKeyCapture).toBe(false);
expect(outsidePressCapture).toBe(true);
});
test('true', () => {
const {escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture} =
normalizeProp(true);
expect(escapeKeyCapture).toBe(true);
expect(outsidePressCapture).toBe(true);
});
test('false', () => {
const {escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture} =
normalizeProp(false);
expect(escapeKeyCapture).toBe(false);
expect(outsidePressCapture).toBe(false);
});
test('{ escapeKey: true }', () => {
const {escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture} =
normalizeProp({
escapeKey: true,
});
expect(escapeKeyCapture).toBe(true);
expect(outsidePressCapture).toBe(true);
});
test('{ outsidePress: false }', () => {
const {escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture} =
normalizeProp({
outsidePress: false,
});
expect(escapeKeyCapture).toBe(false);
expect(outsidePressCapture).toBe(false);
});
});
const Overlay = ({children}: {children: ReactNode}) => {
return (
e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.stopPropagation();
}
}}
>
outside
{children}
);
};
const Dialog = ({
id,
children,
...props
}: UseDismissProps & {id: string; children: ReactNode}) => {
const [open, setOpen] = useState(true);
const nodeId = useFloatingNodeId();
const {refs, context} = useFloating({
open,
onOpenChange: setOpen,
nodeId,
});
const {getReferenceProps, getFloatingProps} = useInteractions([
useDismiss(context, props),
]);
return (
{open && (
{id}
{children}
)}
);
};
const NestedDialog = (
props: UseDismissProps & {id: string; children: ReactNode},
) => {
const parentId = useFloatingParentNodeId();
if (parentId == null) {
return (
);
}
return ;
};
describe('outsidePress', () => {
test('false', async () => {
const user = userEvent.setup();
render(
{null}
,
);
expect(screen.getByText('outer')).toBeInTheDocument();
expect(screen.getByText('inner')).toBeInTheDocument();
await user.click(screen.getByText('outer'));
expect(screen.getByText('outer')).toBeInTheDocument();
expect(screen.getByText('inner')).toBeInTheDocument();
await user.click(screen.getByText('outside'));
expect(screen.getByText('outer')).toBeInTheDocument();
expect(screen.getByText('inner')).toBeInTheDocument();
cleanup();
});
test('true', async () => {
const user = userEvent.setup();
render(
{null}
,
);
expect(screen.getByText('outer')).toBeInTheDocument();
expect(screen.getByText('inner')).toBeInTheDocument();
await user.click(screen.getByText('outer'));
expect(screen.getByText('outer')).toBeInTheDocument();
expect(screen.queryByText('inner')).not.toBeInTheDocument();
await user.click(screen.getByText('outside'));
expect(screen.queryByText('outer')).not.toBeInTheDocument();
expect(screen.queryByText('inner')).not.toBeInTheDocument();
cleanup();
});
});
describe('escapeKey', () => {
test('false', async () => {
const user = userEvent.setup();
render(
{null}
,
);
expect(screen.getByText('outer')).toBeInTheDocument();
expect(screen.getByText('inner')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.getByText('outer')).toBeInTheDocument();
expect(screen.queryByText('inner')).not.toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByText('outer')).not.toBeInTheDocument();
expect(screen.queryByText('inner')).not.toBeInTheDocument();
cleanup();
});
test('true', async () => {
const user = userEvent.setup();
render(
{null}
,
);
expect(screen.getByText('outer')).toBeInTheDocument();
expect(screen.getByText('inner')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.getByText('outer')).toBeInTheDocument();
expect(screen.queryByText('inner')).not.toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByText('outer')).not.toBeInTheDocument();
expect(screen.queryByText('inner')).not.toBeInTheDocument();
cleanup();
});
});
});
describe('outsidePressEvent click', () => {
test('dragging outside the floating element does not close', () => {
render();
const floatingEl = screen.getByRole('tooltip');
fireEvent.mouseDown(floatingEl);
fireEvent.mouseUp(document.body);
expect(screen.queryByRole('tooltip')).toBeInTheDocument();
cleanup();
});
test('dragging inside the floating element does not close', () => {
render();
const floatingEl = screen.getByRole('tooltip');
fireEvent.mouseDown(document.body);
fireEvent.mouseUp(floatingEl);
expect(screen.queryByRole('tooltip')).toBeInTheDocument();
cleanup();
});
test('dragging outside the floating element then clicking outside closes', async () => {
render();
const floatingEl = screen.getByRole('tooltip');
fireEvent.mouseDown(floatingEl);
fireEvent.mouseUp(document.body);
// A click event will have fired before the proper outside click.
fireEvent.click(document.body);
fireEvent.click(document.body);
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
cleanup();
});
});