Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
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>
141 changes: 141 additions & 0 deletions docs/examples/safe-hover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import Trigger from '@rc-component/trigger';
import type { TriggerRef } from '@rc-component/trigger';
import React, { useState } from 'react';
import { getSafeHoverAreaPolygons } from '../../src/util/safeHover';
import type { SafeHoverPoint } from '../../src/util/safeHover';
import '../../assets/index.less';

interface SafeHoverPolygon {
points: SafeHoverPoint[];
fill: string;
stroke: string;
}

const safeHoverPolygonStyles = [
{
fill: 'rgba(255, 176, 32, 0.22)',
stroke: 'rgba(222, 121, 0, 0.6)',
},
{
fill: 'rgba(22, 119, 255, 0.16)',
stroke: 'rgba(22, 119, 255, 0.55)',
},
];

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: React.FC = () => {
const triggerRef = React.useRef<TriggerRef>(null);

const [safeHoverPolygons, setPolygons] = useState<SafeHoverPolygon[]>([]);

const updateSafeHoverPolygons = (
event: React.MouseEvent<HTMLElement> | React.PointerEvent<HTMLElement>,
) => {
const target = triggerRef.current?.nativeElement;
const popup = triggerRef.current?.popupElement;

if (!target || !popup) {
setPolygons([]);
return;
}

const leavePoint: SafeHoverPoint = [event.clientX, event.clientY];

setPolygons(
getSafeHoverAreaPolygons(
leavePoint,
target.getBoundingClientRect(),
popup.getBoundingClientRect(),
).map((points, i) => ({ points, ...safeHoverPolygonStyles[i] })),
);
};

return (
<div style={{ minHeight: 320, padding: '160px 80px 80px' }}>
{safeHoverPolygons.length > 0 && (
<svg
aria-hidden
style={{
position: 'fixed',
inset: 0,
width: '100vw',
height: '100vh',
pointerEvents: 'none',
zIndex: 999,
}}
>
{safeHoverPolygons.map(({ points, fill, stroke }, index) => {
return (
<polygon
// eslint-disable-next-line react/no-array-index-key
key={`polygon-${index}`}
points={points.map((point) => point.join(',')).join(' ')}
fill={fill}
stroke={stroke}
strokeDasharray="4 3"
strokeWidth={1}
/>
);
})}
</svg>
)}
<Trigger
ref={triggerRef}
action={['hover']}
mouseLeaveDelay={0.12}
popupPlacement="top"
builtinPlacements={builtinPlacements}
popupStyle={popupStyle}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setPolygons([]);
}
}}
popup={
<div onMouseEnter={() => setPolygons([])}>
<strong>Safe hover popup</strong>
<div style={{ marginTop: 8 }}>
Move through the gap to reach me.
</div>
</div>
}
>
<button
style={{ padding: '8px 16px' }}
type="button"
onMouseLeave={updateSafeHoverPolygons}
onPointerLeave={updateSafeHoverPolygons}
>
trigger
</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;
152 changes: 138 additions & 14 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
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,121 @@
}, 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);

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

safeHoverRef.current = null;
}
});

const startSafeHover = useEvent(
(
event: React.MouseEvent<HTMLElement> | React.PointerEvent<HTMLElement>,
) => {
if (
!targetEle ||
!popupEle ||
!openRef.current ||
mouseLeaveDelay <= 0
) {
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

Check warning

Code scanning / CodeQL

Useless comparison test Warning

The condition 'mouseLeaveDelay > 0' is always true.
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(1000 / 60, Math.min(mouseLeaveDelay * 1000, 1000))
: 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);

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 +785,7 @@

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

Expand All @@ -688,6 +806,8 @@
);

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

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

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