Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ export function useHoverFloatingInteraction(
}

clearPointerEvents();
if (!isClickLikeOpenEvent()) {
if (isHoverOpen() && !isClickLikeOpenEvent()) {
closeWithDelay(event);
}
}
Expand Down Expand Up @@ -262,6 +262,7 @@ export function useHoverFloatingInteraction(
dataRef,
closeDelayProp,
nodeIdProp,
isHoverOpen,
isClickLikeOpenEvent,
clearPointerEvents,
instance,
Expand Down
78 changes: 78 additions & 0 deletions packages/react/src/menu/root/MenuRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1228,6 +1228,84 @@ describe('<Menu.Root />', () => {
});
});

describe('hover close', () => {
it('does not close after hovering out of a popup opened without trigger hover', async () => {
await render(<TestMenu rootProps={{ defaultOpen: true }} />);

await waitFor(() => {
expect(screen.queryByRole('menu')).not.toBe(null);
});

const positioner = screen.getByTestId('menu-positioner');

fireEvent.mouseEnter(positioner);
fireEvent.mouseLeave(positioner);

expect(screen.queryByRole('menu')).not.toBe(null);
});
});

describe('controlled open', () => {
it('does not close after hovering out of a popup opened externally', async () => {
function App() {
const [open, setOpen] = React.useState(false);

return (
<React.Fragment>
<button type="button" onClick={() => setOpen(true)}>
Show
</button>
<TestMenu rootProps={{ open, onOpenChange: setOpen }} />
</React.Fragment>
);
}

const { user } = await render(<App />);

await user.click(screen.getByRole('button', { name: 'Show' }));

await waitFor(() => {
expect(screen.queryByRole('menu')).not.toBe(null);
});

const positioner = screen.getByTestId('menu-positioner');

fireEvent.mouseEnter(positioner);
fireEvent.mouseLeave(positioner);

expect(screen.queryByRole('menu')).not.toBe(null);
});

it('closes after hovering out of a popup opened by its trigger', async () => {
function App() {
const [open, setOpen] = React.useState(false);

return (
<TestMenu
rootProps={{ open, onOpenChange: setOpen, modal: false }}
triggerProps={{ openOnHover: true, delay: 0 }}
/>
);
}

await render(<App />);

const trigger = screen.getByRole('button', { name: 'Toggle' });

fireEvent.mouseEnter(trigger);
fireEvent.mouseMove(trigger);

expect(screen.queryByRole('menu')).not.toBe(null);

const positioner = screen.getByTestId('menu-positioner');

fireEvent.mouseEnter(positioner);
fireEvent.mouseLeave(positioner);

expect(screen.queryByRole('menu')).toBe(null);
});
});

describe.skipIf(isJSDOM)('scroll locking', () => {
describe('interaction type tracking (openMethod)', () => {
it('should not apply scroll lock when opened via touch', async () => {
Expand Down
128 changes: 128 additions & 0 deletions packages/react/src/popover/root/PopoverRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,119 @@ describe('<Popover.Root />', () => {
expect(handleChange.mock.calls[0][0]).toBe(false);
expect(handleChange.mock.calls[1][0]).toBe(true);
});

it('does not close after hovering out of a popup opened externally', async () => {
function App() {
const [open, setOpen] = React.useState(false);

return (
<React.Fragment>
<button type="button" onClick={() => setOpen(true)}>
Show
</button>
<TestPopover
rootProps={{ open, onOpenChange: setOpen }}
triggerProps={{ openOnHover: true, delay: 0 }}
/>
</React.Fragment>
);
}

const { user } = await render(<App />);

await user.click(screen.getByRole('button', { name: 'Show' }));

await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBe(null);
});

const positioner = screen.getByTestId('positioner');

fireEvent.mouseEnter(positioner);
fireEvent.mouseLeave(positioner);

expect(screen.queryByRole('dialog')).not.toBe(null);
});

it('closes after hovering out of a popup opened by its trigger', async () => {
function App() {
const [open, setOpen] = React.useState(false);

return (
<TestPopover
rootProps={{ open, onOpenChange: setOpen }}
triggerProps={{ openOnHover: true, delay: 0 }}
/>
);
}

await render(<App />);

const trigger = screen.getByRole('button', { name: 'Toggle' });

fireEvent.mouseEnter(trigger);
fireEvent.mouseMove(trigger);

expect(screen.queryByRole('dialog')).not.toBe(null);

const positioner = screen.getByTestId('positioner');

fireEvent.mouseEnter(positioner);
fireEvent.mouseLeave(positioner);

expect(screen.queryByRole('dialog')).toBe(null);
});

it('cleans up the safe polygon handler after a hover-opened popup becomes click-sticky', async () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');

try {
await render(
<TestPopover
rootProps={{ modal: false }}
triggerProps={{ openOnHover: true, delay: 0, closeDelay: 0 }}
/>,
);

const trigger = screen.getByRole('button', { name: 'Toggle' });

fireEvent.mouseEnter(trigger);
fireEvent.mouseMove(trigger);

expect(screen.queryByRole('dialog')).not.toBe(null);

const positioner = screen.getByTestId('positioner');

fireEvent.mouseLeave(trigger, { relatedTarget: positioner });
fireEvent.mouseEnter(positioner);

let documentMouseMoveHandler: EventListenerOrEventListenerObject | undefined;
for (let i = addEventListenerSpy.mock.calls.length - 1; i >= 0; i -= 1) {
const [eventName, listener] = addEventListenerSpy.mock.calls[i];
if (eventName === 'mousemove') {
documentMouseMoveHandler = listener;
break;
}
}

expect(documentMouseMoveHandler).toEqual(expect.any(Function));

fireEvent.click(trigger);
await flushMicrotasks();

fireEvent.mouseLeave(positioner);

expect(screen.queryByRole('dialog')).not.toBe(null);
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'mousemove',
documentMouseMoveHandler,
);
} finally {
addEventListenerSpy.mockRestore();
removeEventListenerSpy.mockRestore();
}
});
});

describe('nested menu interactions', () => {
Expand Down Expand Up @@ -291,6 +404,21 @@ describe('<Popover.Root />', () => {

expect(screen.queryByText('Content')).toBe(null);
});

it('does not close after hovering out of a popup opened without trigger hover', async () => {
await render(
<TestPopover rootProps={{ defaultOpen: true }} triggerProps={{ openOnHover: true }} />,
);

expect(screen.getByText('Content')).not.toBe(null);

const positioner = screen.getByTestId('positioner');

fireEvent.mouseEnter(positioner);
fireEvent.mouseLeave(positioner);

expect(screen.getByText('Content')).not.toBe(null);
});
});

describe('prop: delay', () => {
Expand Down
96 changes: 93 additions & 3 deletions packages/react/src/preview-card/root/PreviewCardRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,68 @@ describe('<PreviewCard.Root />', () => {
expect(handleChange.mock.calls[1][0]).toBe(true);
});

it('does not close after hovering out of a popup opened externally', async () => {
function App() {
const [open, setOpen] = React.useState(false);

return (
<React.Fragment>
<button type="button" onClick={() => setOpen(true)}>
Show
</button>
<TestPreviewCard rootProps={{ open, onOpenChange: setOpen }} />
</React.Fragment>
);
}

await render(<App />);

fireEvent.click(screen.getByRole('button', { name: 'Show' }));

expect(screen.queryByText('Content')).not.toBe(null);

const positioner = screen.getByTestId('positioner');

fireEvent.mouseEnter(positioner);
fireEvent.mouseLeave(positioner);

clock.tick(CLOSE_DELAY);

await flushMicrotasks();

expect(screen.queryByText('Content')).not.toBe(null);
});

it('closes after hovering out of a popup opened by its trigger', async () => {
function App() {
const [open, setOpen] = React.useState(false);

return <TestPreviewCard rootProps={{ open, onOpenChange: setOpen }} />;
}

await render(<App />);

const trigger = screen.getByRole('link', { name: 'Link' });

fireEvent.mouseEnter(trigger);
fireEvent.mouseMove(trigger);

clock.tick(OPEN_DELAY);
await flushMicrotasks();

expect(screen.queryByText('Content')).not.toBe(null);

const positioner = screen.getByTestId('positioner');

fireEvent.mouseEnter(positioner);
fireEvent.mouseLeave(positioner);

clock.tick(CLOSE_DELAY);
await flushMicrotasks();

expect(screen.queryByText('Content')).toBe(null);
});

it('should not call onChange when the open state does not change', async () => {
const handleChange = vi.fn();

Expand Down Expand Up @@ -254,6 +316,28 @@ describe('<PreviewCard.Root />', () => {

expect(screen.queryByText('Content')).toBe(null);
});

it('does not close after hovering out of a popup opened without trigger hover', async () => {
await render(
<TestPreviewCard
rootProps={{
defaultOpen: true,
}}
/>,
);

expect(screen.getByText('Content')).not.toBe(null);

const positioner = screen.getByTestId('positioner');

fireEvent.mouseEnter(positioner);
fireEvent.mouseLeave(positioner);

clock.tick(CLOSE_DELAY);
await flushMicrotasks();

expect(screen.getByText('Content')).not.toBe(null);
});
});

describe('prop: delay', () => {
Expand Down Expand Up @@ -828,7 +912,7 @@ describe('<PreviewCard.Root />', () => {
<PreviewCard.Positioner>
<PreviewCard.Popup data-testid="parent-popup">
<div>Parent content</div>
<PreviewCard.Root defaultOpen>
<PreviewCard.Root>
<PreviewCard.Trigger href="#" data-testid="child-trigger">
Child
</PreviewCard.Trigger>
Expand All @@ -851,6 +935,14 @@ describe('<PreviewCard.Root />', () => {

// Events must be triggered on positioner elements (parent of popup)
const parentPopup = screen.getByTestId('parent-popup').parentElement!;
const childTrigger = screen.getByTestId('child-trigger');

fireEvent.mouseEnter(childTrigger);
fireEvent.mouseMove(childTrigger);

clock.tick(OPEN_DELAY);
await flushMicrotasks();

let childPopup = screen.getByTestId('child-popup').parentElement!;

// Step 3: Move mouse outside all previews
Expand All @@ -874,8 +966,6 @@ describe('<PreviewCard.Root />', () => {
expect(screen.queryByTestId('child-popup')).toBe(null);

// Step 5: Hover child trigger again to re-open child
const childTrigger = screen.getByTestId('child-trigger');

fireEvent.mouseEnter(childTrigger);
fireEvent.mouseMove(childTrigger);

Expand Down
Loading