Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 8 additions & 0 deletions docs/demos/safe-hover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: Safe Hover
nav:
title: Demo
path: /demo
---

<code src="../examples/safe-hover.tsx"></code>
61 changes: 61 additions & 0 deletions docs/examples/safe-hover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Trigger from '@rc-component/trigger';
import React from 'react';
import '../../assets/index.less';

const builtinPlacements = {
top: {
points: ['bc', 'tc'],
offset: [0, -56],
},
};

const popupStyle: React.CSSProperties = {
width: 240,
padding: 12,
background: '#fff',
border: '1px solid #d9d9d9',
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.12)',
};

const SafeHoverDemo = () => {
return (
<div style={{ minHeight: 320, padding: '160px 80px 80px' }}>
<Trigger
action={['hover']}
mouseLeaveDelay={0.12}
popupPlacement="top"
builtinPlacements={builtinPlacements}
popupStyle={popupStyle}
popup={
<div>
<strong>Safe hover popup</strong>
<div style={{ marginTop: 8 }}>
Move through the gap to reach me.
</div>
<button style={{ marginTop: 12 }} type="button">
Popup action
</button>
</div>
}
>
<button style={{ padding: '8px 16px' }} type="button">
Hover target
</button>
</Trigger>

<div
style={{
width: 240,
marginTop: 16,
color: '#666',
fontSize: 13,
lineHeight: 1.5,
}}
>
The popup is offset upward, leaving a blank hover gap.
</div>
</div>
);
};

export default SafeHoverDemo;
151 changes: 137 additions & 14 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import useDelay from './hooks/useDelay';
import useWatch from './hooks/useWatch';
import useWinClick from './hooks/useWinClick';
import type { PortalProps } from '@rc-component/portal';
import { isPointInSafeHoverArea } from './util/safeHover';
import type { SafeHoverPoint } from './util/safeHover';

import type {
ActionType,
Expand Down Expand Up @@ -421,6 +423,120 @@ export function generateTrigger(
}, delay);
};

const safeHoverRef = React.useRef<{
doc: Document;
handler: (event: MouseEvent) => void;
refreshTimer: ReturnType<typeof setTimeout> | null;
} | null>(null);

const clearSafeHover = useEvent(() => {
const safeHover = safeHoverRef.current;

if (safeHover) {
safeHover.doc.removeEventListener('mousemove', safeHover.handler);
safeHover.doc.removeEventListener('pointermove', safeHover.handler);

if (safeHover.refreshTimer) {
clearTimeout(safeHover.refreshTimer);
}

safeHoverRef.current = null;
}
});

const startSafeHover = useEvent(
(
event: React.MouseEvent<HTMLElement> | React.PointerEvent<HTMLElement>,
) => {
if (!targetEle || !popupEle || !openRef.current) {
return false;
}

const leavePoint: SafeHoverPoint = [event.clientX, event.clientY];
const targetRect = targetEle.getBoundingClientRect();
const popupRect = popupEle.getBoundingClientRect();

if (
!isPointInSafeHoverArea(leavePoint, leavePoint, targetRect, popupRect)
) {
return false;
}

const doc = targetEle.ownerDocument;

clearSafeHover();

let latestPoint = leavePoint;

const isPointSafe = (point: SafeHoverPoint) =>
isPointInSafeHoverArea(
point,
leavePoint,
targetEle.getBoundingClientRect(),
popupEle.getBoundingClientRect(),
);

const refreshDelay =
mouseLeaveDelay > 0
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
? Math.max(16, Math.min(mouseLeaveDelay * 500, 100))
: 0;
const scheduleRefresh = () => {
const safeHover = safeHoverRef.current;

if (!safeHover || !refreshDelay) {
return;
}

safeHover.refreshTimer = setTimeout(() => {
if (isPointSafe(latestPoint)) {
triggerOpen(false, mouseLeaveDelay);
scheduleRefresh();
} else {
clearSafeHover();
triggerOpen(false, mouseLeaveDelay);
}
}, refreshDelay);
};

// Keep the existing close delay alive while the cursor crosses the gap.
const handler = (nativeEvent: MouseEvent) => {
latestPoint = [nativeEvent.clientX, nativeEvent.clientY];

if (isPointSafe(latestPoint)) {
triggerOpen(false, mouseLeaveDelay);
} else {
clearSafeHover();
triggerOpen(false, mouseLeaveDelay);
}
};

doc.addEventListener('mousemove', handler);
doc.addEventListener('pointermove', handler);
safeHoverRef.current = {
doc,
handler,
refreshTimer: null,
};

triggerOpen(false, mouseLeaveDelay);
scheduleRefresh();

return true;
},
);

React.useEffect(() => {
return () => {
clearSafeHover();
};
}, [clearSafeHover]);

useLayoutEffect(() => {
if (!mergedOpen) {
clearSafeHover();
}
}, [mergedOpen, clearSafeHover]);

function onEsc({ top }: Parameters<PortalProps['onEsc']>[0]) {
if (top) {
triggerOpen(false);
Expand Down Expand Up @@ -668,6 +784,7 @@ export function generateTrigger(

if (hoverToShow) {
const onMouseEnterCallback = (event: React.MouseEvent) => {
clearSafeHover();
setMousePosByEvent(event);
};

Expand All @@ -688,6 +805,8 @@ export function generateTrigger(
);

onPopupMouseEnter = (event) => {
clearSafeHover();

// Only trigger re-open when popup is visible
if (
(mergedOpen || inMotion) &&
Expand All @@ -706,22 +825,26 @@ export function generateTrigger(
}

if (hoverToHide) {
wrapperAction(
'onMouseLeave',
false,
mouseLeaveDelay,
undefined,
ignoreMouseTrigger,
);
wrapperAction(
'onPointerLeave',
false,
mouseLeaveDelay,
undefined,
ignoreMouseTrigger,
);
cloneProps.onMouseLeave = (event, ...args) => {
if (!ignoreMouseTrigger() && !startSafeHover(event)) {
triggerOpen(false, mouseLeaveDelay);
}

// Pass to origin
originChildProps.onMouseLeave?.(event, ...args);
};

cloneProps.onPointerLeave = (event, ...args) => {
if (!ignoreMouseTrigger() && !startSafeHover(event)) {
triggerOpen(false, mouseLeaveDelay);
}

// Pass to origin
originChildProps.onPointerLeave?.(event, ...args);
};

onPopupMouseLeave = () => {
clearSafeHover();
triggerOpen(false, mouseLeaveDelay);
};
}
Expand Down
Loading
Loading