Skip to content
Open
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
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;
2 changes: 1 addition & 1 deletion src/Popup/Arrow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { AlignType, ArrowPos, ArrowTypeOuter } from '../interface';

export interface ArrowProps {
prefixCls: string;
align: AlignType;
align?: AlignType;
arrow: ArrowTypeOuter;
arrowPos: ArrowPos;
}
Expand Down
4 changes: 2 additions & 2 deletions src/hooks/useOffsetStyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ export default function useOffsetStyle(
const { points } = align;
const dynamicInset =
align.dynamicInset || (align as any)._experimental?.dynamicInset;
const alignRight = dynamicInset && points[0][1] === 'r';
const alignBottom = dynamicInset && points[0][0] === 'b';
const alignRight = dynamicInset && points?.[0][1] === 'r';
const alignBottom = dynamicInset && points?.[0][0] === 'b';

if (alignRight) {
offsetStyle.right = offsetR;
Expand Down
162 changes: 143 additions & 19 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import {
useLayoutEffect,
} from '@rc-component/util';
import * as React from 'react';
import Popup, { type MobileConfig } from './Popup';
import Popup from './Popup';
import type { MobileConfig } from './Popup';
import type { TriggerContextProps } from './context';
import TriggerContext, { UniqueContext } from './context';
import useAction from './hooks/useAction';
Expand All @@ -22,15 +23,16 @@ 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,
AlignType,
ArrowPos,
ArrowTypeOuter,
BuildInPlacements,
} from './interface';
import { getAlignPopupClassName } from './util';
import { clamp, getAlignPopupClassName } from './util';

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

function onEsc({ top }: Parameters<PortalProps['onEsc']>[0]) {
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(),
);

// Between 1 frame and 1 second
const refreshDelay = clamp(mouseLeaveDelay * 1000, 1000 / 60, 1000);

const cancelRefresh = () => {
const safeHover = safeHoverRef.current;
if (safeHover?.refreshTimer) {
clearTimeout(safeHover.refreshTimer);
safeHover.refreshTimer = null;
}
};

const scheduleRefresh = () => {
const safeHover = safeHoverRef.current;

if (!safeHover || safeHover.refreshTimer) {
return;
}

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

const handler = (nativeEvent: MouseEvent) => {
latestPoint = [nativeEvent.clientX, nativeEvent.clientY];

if (isPointSafe(latestPoint)) {
cancelRefresh();
} else {
scheduleRefresh();
}
};

doc.addEventListener('mousemove', handler);

safeHoverRef.current = { doc, handler, refreshTimer: null };

return true;
},
);

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

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

function onEsc({ top }: Parameters<NonNullable<PortalProps['onEsc']>>[0]) {
if (top) {
triggerOpen(false);
}
Expand All @@ -440,7 +557,7 @@ export function generateTrigger(
);

const [motionPrepareResolve, setMotionPrepareResolve] =
React.useState<VoidFunction>(null);
React.useState<VoidFunction | null>(null);

// =========================== Align ============================
const [mousePos, setMousePos] = React.useState<
Expand Down Expand Up @@ -668,6 +785,7 @@ export function generateTrigger(

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

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

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

// Only trigger re-open when popup is visible
if (
(mergedOpen || inMotion) &&
Expand All @@ -706,22 +826,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