diff --git a/.github/workflows/react-component-ci.yml b/.github/workflows/react-component-ci.yml
index 1d160790..10c0ebb6 100644
--- a/.github/workflows/react-component-ci.yml
+++ b/.github/workflows/react-component-ci.yml
@@ -13,4 +13,4 @@ jobs:
test:
uses: react-component/rc-test/.github/workflows/test-utoo.yml@main
secrets:
- CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
\ No newline at end of file
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
diff --git a/README.md b/README.md
index a66da5b6..61be3c87 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,6 @@
English | 简体中文
-
## Highlights
- Built for React and maintained by the rc-component team.
@@ -64,47 +63,47 @@ Then open `http://localhost:8000`.
### Trigger
-| Prop | Description | Type | Default |
-| -------------------------- | ------------------------------------------------------------------------- | ----------------------------------------- | ------------------ |
-| action | Actions that control popup visibility. | `ActionType` \| `ActionType[]` | `hover` |
-| afterOpenChange | Called after popup visibility changes. | `(visible) => void` | - |
-| afterPopupVisibleChange | Deprecated. Use `afterOpenChange` instead. | `(visible) => void` | - |
-| alignPoint | Align popup to mouse position for click, hover, and context menu actions. | `boolean` | `false` |
-| arrow | Render popup arrow. | `boolean` \| `ArrowType` | - |
-| autoDestroy | Destroy popup DOM when it is hidden. | `boolean` | `false` |
-| blurDelay | Delay before hiding on blur, in seconds. | `number` | - |
-| builtinPlacements | Named placement presets. | `BuildInPlacements` | `{}` |
-| defaultPopupVisible | Initial uncontrolled visibility. | `boolean` | `false` |
+| Prop | Description | Type | Default |
+| -------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------- | ------------------ |
+| action | Actions that control popup visibility. | `ActionType` \| `ActionType[]` | `hover` |
+| afterOpenChange | Called after popup visibility changes. | `(visible) => void` | - |
+| afterPopupVisibleChange | Deprecated. Use `afterOpenChange` instead. | `(visible) => void` | - |
+| alignPoint | Align popup to mouse position for click, hover, and context menu actions. | `boolean` | `false` |
+| arrow | Render popup arrow. | `boolean` \| `ArrowType` | - |
+| autoDestroy | Destroy popup DOM when it is hidden. | `boolean` | `false` |
+| blurDelay | Delay before hiding on blur, in seconds. | `number` | - |
+| builtinPlacements | Named placement presets. | `BuildInPlacements` | `{}` |
+| defaultPopupVisible | Initial uncontrolled visibility. | `boolean` | `false` |
| disabled | Temporarily suppress popup visibility without resetting the current open state. | `boolean` | `false` |
-| focusDelay | Delay before showing on focus, in seconds. | `number` | - |
-| forceRender | Render popup before it is first shown. | `boolean` | `false` |
-| fresh | Keep popup content updated while closed. | `boolean` | - |
-| getPopupClassNameFromAlign | Return a popup class name from the current alignment. | `(align) => string` | - |
-| getPopupContainer | Return the element that should contain the popup. | `(node) => HTMLElement` | - |
-| hideAction | Actions that hide popup visibility. | `ActionType[]` | - |
-| mask | Render a mask behind the popup. | `boolean` | `false` |
-| maskClosable | Close when clicking the mask. | `boolean` | `true` |
-| maskMotion | Motion config for the mask. | `CSSMotionProps` | - |
-| mouseEnterDelay | Delay before showing on mouse enter, in seconds. | `number` | - |
-| mouseLeaveDelay | Delay before hiding on mouse leave, in seconds. | `number` | `0.1` |
-| popup | Popup content. | `ReactNode` \| `() => ReactNode` | - |
-| popupAlign | Alignment config compatible with dom-align. | `AlignType` | - |
-| popupClassName | Class name added to popup. | `string` | - |
-| popupMotion | Motion config for the popup. | `CSSMotionProps` | - |
-| popupPlacement | Placement key from `builtinPlacements`. | `string` | - |
-| popupStyle | Inline popup style. | `React.CSSProperties` | - |
-| popupVisible | Controlled popup visibility. | `boolean` | - |
-| prefixCls | Popup class name prefix. | `string` | `rc-trigger-popup` |
-| showAction | Actions that show popup visibility. | `ActionType[]` | - |
-| stretch | Stretch popup width and/or height from target. | `string` | - |
-| unique | Share popup container through `UniqueProvider`. | `boolean` | - |
-| uniqueContainerClassName | Class name passed to `UniqueProvider` container. | `string` | - |
-| uniqueContainerStyle | Style passed to `UniqueProvider` container. | `React.CSSProperties` | - |
-| zIndex | Popup z-index. | `number` | - |
-| onOpenChange | Called when visibility changes. | `(visible) => void` | - |
-| onPopupAlign | Called when the popup is aligned. | `(element, align) => void` | - |
-| onPopupClick | Called when popup is clicked. | `React.MouseEventHandler` | - |
-| onPopupVisibleChange | Deprecated. Use `onOpenChange` instead. | `(visible) => void` | - |
+| focusDelay | Delay before showing on focus, in seconds. | `number` | - |
+| forceRender | Render popup before it is first shown. | `boolean` | `false` |
+| fresh | Keep popup content updated while closed. | `boolean` | - |
+| getPopupClassNameFromAlign | Return a popup class name from the current alignment. | `(align) => string` | - |
+| getPopupContainer | Return the element that should contain the popup. | `(node) => HTMLElement` | - |
+| hideAction | Actions that hide popup visibility. | `ActionType[]` | - |
+| mask | Render a mask behind the popup. | `boolean` | `false` |
+| maskClosable | Close when clicking the mask. | `boolean` | `true` |
+| maskMotion | Motion config for the mask. | `CSSMotionProps` | - |
+| mouseEnterDelay | Delay before showing on mouse enter, in seconds. | `number` | - |
+| mouseLeaveDelay | Delay before hiding on mouse leave, in seconds. | `number` | `0.1` |
+| popup | Popup content. | `ReactNode` \| `() => ReactNode` | - |
+| popupAlign | Alignment config compatible with dom-align. | `AlignType` | - |
+| popupClassName | Class name added to popup. | `string` | - |
+| popupMotion | Motion config for the popup. | `CSSMotionProps` | - |
+| popupPlacement | Placement key from `builtinPlacements`. | `string` | - |
+| popupStyle | Inline popup style. | `React.CSSProperties` | - |
+| popupVisible | Controlled popup visibility. | `boolean` | - |
+| prefixCls | Popup class name prefix. | `string` | `rc-trigger-popup` |
+| showAction | Actions that show popup visibility. | `ActionType[]` | - |
+| stretch | Stretch popup width and/or height from target. | `string` | - |
+| unique | Share popup container through `UniqueProvider`. | `boolean` | - |
+| uniqueContainerClassName | Class name passed to `UniqueProvider` container. | `string` | - |
+| uniqueContainerStyle | Style passed to `UniqueProvider` container. | `React.CSSProperties` | - |
+| zIndex | Popup z-index. | `number` | - |
+| onOpenChange | Called when visibility changes. | `(visible) => void` | - |
+| onPopupAlign | Called when the popup is aligned. | `(element, align) => void` | - |
+| onPopupClick | Called when popup is clicked. | `React.MouseEventHandler` | - |
+| onPopupVisibleChange | Deprecated. Use `onOpenChange` instead. | `(visible) => void` | - |
## Development
diff --git a/README.zh-CN.md b/README.zh-CN.md
index d99a82f3..cb0c5612 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -15,7 +15,6 @@
English | 简体中文
-
## 特性
- 面向 React 构建,并由 rc-component 团队维护。
@@ -64,47 +63,47 @@ npm start
### Trigger
-| 属性 | 说明 | 类型 | 默认值 |
-| -------------------------- | ------------------------------------------------------------------------- | ----------------------------------------- | ------------------ |
-| action | 控制弹层显示状态的触发行为。 | `ActionType` \| `ActionType[]` | `hover` |
-| afterOpenChange | 弹层窗口可见性更改后调用。 | `(visible) => void` | - |
-| afterPopupVisibleChange | 已废弃。请使用 `afterOpenChange`。 | `(visible) => void` | - |
+| 属性 | 说明 | 类型 | 默认值 |
+| -------------------------- | ---------------------------------------------------------------- | ----------------------------------------- | ------------------ |
+| action | 控制弹层显示状态的触发行为。 | `ActionType` \| `ActionType[]` | `hover` |
+| afterOpenChange | 弹层窗口可见性更改后调用。 | `(visible) => void` | - |
+| afterPopupVisibleChange | 已废弃。请使用 `afterOpenChange`。 | `(visible) => void` | - |
| alignPoint | 将弹层对齐到 `click`、`hover` 和 `context menu` 行为的鼠标位置。 | `boolean` | `false` |
-| arrow | 渲染弹层箭头。 | `boolean` \| `ArrowType` | - |
-| autoDestroy | 弹层隐藏时销毁弹层 DOM。 | `boolean` | `false` |
-| blurDelay | `blur` 后隐藏前的延迟,单位为秒。 | `number` | - |
-| builtinPlacements | 命名位置预设。 | `BuildInPlacements` | `{}` |
-| defaultPopupVisible | 非受控初始显示状态。 | `boolean` | `false` |
-| disabled | 临时隐藏弹层,但不主动重置当前打开状态。 | `boolean` | `false` |
-| focusDelay | `focus` 后显示前的延迟,单位为秒。 | `number` | - |
-| forceRender | 首次显示前渲染弹层。 | `boolean` | `false` |
-| fresh | 关闭时仍保持弹层内容更新。 | `boolean` | - |
-| getPopupClassNameFromAlign | 根据当前对齐信息返回弹层 className。 | `(align) => string` | - |
-| getPopupContainer | 返回弹层挂载容器元素。 | `(node) => HTMLElement` | - |
-| hideAction | 隐藏弹层的触发行为。 | `ActionType[]` | - |
-| mask | 在弹层后渲染遮罩。 | `boolean` | `false` |
-| maskClosable | 点击遮罩时关闭。 | `boolean` | `true` |
-| maskMotion | 遮罩动画配置。 | `CSSMotionProps` | - |
-| mouseEnterDelay | mouse enter 后显示前的延迟,单位为秒。 | `number` | - |
-| mouseLeaveDelay | 鼠标离开后隐藏前的延迟,单位为秒。 | `number` | `0.1` |
-| popup | 弹层内容。 | `ReactNode` \| `() => ReactNode` | - |
-| popupAlign | 与 dom-align 兼容的对齐配置。 | `AlignType` | - |
-| popupClassName | 添加到弹层的 className。 | `string` | - |
-| popupMotion | 弹层动画配置。 | `CSSMotionProps` | - |
-| popupPlacement | 来自 `builtinPlacements` 的位置 key。 | `string` | - |
-| popupStyle | 弹层内联样式。 | `React.CSSProperties` | - |
-| popupVisible | 受控的弹层窗口可见性。 | `boolean` | - |
-| prefixCls | 弹层 className 前缀。 | `string` | `rc-trigger-popup` |
-| showAction | 显示弹层的触发行为。 | `ActionType[]` | - |
-| stretch | 根据目标拉伸弹层宽度和/或高度。 | `string` | - |
-| unique | 通过 `UniqueProvider` 共享弹层容器。 | `boolean` | - |
-| uniqueContainerClassName | 传给 `UniqueProvider` 容器的 className。 | `string` | - |
+| arrow | 渲染弹层箭头。 | `boolean` \| `ArrowType` | - |
+| autoDestroy | 弹层隐藏时销毁弹层 DOM。 | `boolean` | `false` |
+| blurDelay | `blur` 后隐藏前的延迟,单位为秒。 | `number` | - |
+| builtinPlacements | 命名位置预设。 | `BuildInPlacements` | `{}` |
+| defaultPopupVisible | 非受控初始显示状态。 | `boolean` | `false` |
+| disabled | 临时隐藏弹层,但不主动重置当前打开状态。 | `boolean` | `false` |
+| focusDelay | `focus` 后显示前的延迟,单位为秒。 | `number` | - |
+| forceRender | 首次显示前渲染弹层。 | `boolean` | `false` |
+| fresh | 关闭时仍保持弹层内容更新。 | `boolean` | - |
+| getPopupClassNameFromAlign | 根据当前对齐信息返回弹层 className。 | `(align) => string` | - |
+| getPopupContainer | 返回弹层挂载容器元素。 | `(node) => HTMLElement` | - |
+| hideAction | 隐藏弹层的触发行为。 | `ActionType[]` | - |
+| mask | 在弹层后渲染遮罩。 | `boolean` | `false` |
+| maskClosable | 点击遮罩时关闭。 | `boolean` | `true` |
+| maskMotion | 遮罩动画配置。 | `CSSMotionProps` | - |
+| mouseEnterDelay | mouse enter 后显示前的延迟,单位为秒。 | `number` | - |
+| mouseLeaveDelay | 鼠标离开后隐藏前的延迟,单位为秒。 | `number` | `0.1` |
+| popup | 弹层内容。 | `ReactNode` \| `() => ReactNode` | - |
+| popupAlign | 与 dom-align 兼容的对齐配置。 | `AlignType` | - |
+| popupClassName | 添加到弹层的 className。 | `string` | - |
+| popupMotion | 弹层动画配置。 | `CSSMotionProps` | - |
+| popupPlacement | 来自 `builtinPlacements` 的位置 key。 | `string` | - |
+| popupStyle | 弹层内联样式。 | `React.CSSProperties` | - |
+| popupVisible | 受控的弹层窗口可见性。 | `boolean` | - |
+| prefixCls | 弹层 className 前缀。 | `string` | `rc-trigger-popup` |
+| showAction | 显示弹层的触发行为。 | `ActionType[]` | - |
+| stretch | 根据目标拉伸弹层宽度和/或高度。 | `string` | - |
+| unique | 通过 `UniqueProvider` 共享弹层容器。 | `boolean` | - |
+| uniqueContainerClassName | 传给 `UniqueProvider` 容器的 className。 | `string` | - |
| uniqueContainerStyle | 传给 `UniqueProvider` 容器的样式。 | `React.CSSProperties` | - |
-| zIndex | 弹层 z 索引。 | `number` | - |
-| onOpenChange | 当可见性发生变化时调用。 | `(visible) => void` | - |
-| onPopupAlign | 弹层完成对齐时调用。 | `(element, align) => void` | - |
-| onPopupClick | 点击弹层时调用。 | `React.MouseEventHandler` | - |
-| onPopupVisibleChange | 已废弃。请使用 `onOpenChange`。 | `(visible) => void` | - |
+| zIndex | 弹层 z 索引。 | `number` | - |
+| onOpenChange | 当可见性发生变化时调用。 | `(visible) => void` | - |
+| onPopupAlign | 弹层完成对齐时调用。 | `(element, align) => void` | - |
+| onPopupClick | 点击弹层时调用。 | `React.MouseEventHandler` | - |
+| onPopupVisibleChange | 已废弃。请使用 `onOpenChange`。 | `(visible) => void` | - |
## 本地开发
diff --git a/docs/demos/safe-hover.md b/docs/demos/safe-hover.md
new file mode 100644
index 00000000..5df13f22
--- /dev/null
+++ b/docs/demos/safe-hover.md
@@ -0,0 +1,8 @@
+---
+title: Safe Hover
+nav:
+ title: Demo
+ path: /demo
+---
+
+
diff --git a/docs/examples/safe-hover.tsx b/docs/examples/safe-hover.tsx
new file mode 100644
index 00000000..96b6c30a
--- /dev/null
+++ b/docs/examples/safe-hover.tsx
@@ -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(null);
+
+ const [safeHoverPolygons, setPolygons] = useState([]);
+
+ const updateSafeHoverPolygons = (
+ event: React.MouseEvent | React.PointerEvent,
+ ) => {
+ 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 (
+
+ {safeHoverPolygons.length > 0 && (
+
+ )}
+
{
+ if (!nextOpen) {
+ setPolygons([]);
+ }
+ }}
+ popup={
+ setPolygons([])}>
+
Safe hover popup
+
+ Move through the gap to reach me.
+
+
+ }
+ >
+
+
+
+ The popup is offset upward, leaving a blank hover gap.
+
+
+ );
+};
+
+export default SafeHoverDemo;
diff --git a/global.d.ts b/global.d.ts
index 9f2d8202..e0bd355c 100644
--- a/global.d.ts
+++ b/global.d.ts
@@ -8,5 +8,4 @@ declare module '*.css';
declare module '*.less';
declare module 'jsonp';
-
declare module 'moment/locale/zh-cn';
diff --git a/src/Popup/Arrow.tsx b/src/Popup/Arrow.tsx
index 1ab8239e..508ef817 100644
--- a/src/Popup/Arrow.tsx
+++ b/src/Popup/Arrow.tsx
@@ -4,7 +4,7 @@ import type { AlignType, ArrowPos, ArrowTypeOuter } from '../interface';
export interface ArrowProps {
prefixCls: string;
- align: AlignType;
+ align?: AlignType;
arrow: ArrowTypeOuter;
arrowPos: ArrowPos;
}
diff --git a/src/hooks/useOffsetStyle.ts b/src/hooks/useOffsetStyle.ts
index 3c590041..75c2dc2e 100644
--- a/src/hooks/useOffsetStyle.ts
+++ b/src/hooks/useOffsetStyle.ts
@@ -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;
diff --git a/src/index.tsx b/src/index.tsx
index 22f29a0a..fba561c9 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -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';
@@ -22,7 +23,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,
AlignType,
@@ -30,7 +32,7 @@ import type {
ArrowTypeOuter,
BuildInPlacements,
} from './interface';
-import { getAlignPopupClassName } from './util';
+import { clamp, getAlignPopupClassName } from './util';
export type {
ActionType,
@@ -425,7 +427,122 @@ export function generateTrigger(
}, delay);
};
- function onEsc({ top }: Parameters[0]) {
+ const safeHoverRef = React.useRef<{
+ doc: Document;
+ handler: (event: MouseEvent) => void;
+ refreshTimer: ReturnType | 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 | React.PointerEvent,
+ ) => {
+ 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>[0]) {
if (top) {
triggerOpen(false);
}
@@ -444,7 +561,7 @@ export function generateTrigger(
);
const [motionPrepareResolve, setMotionPrepareResolve] =
- React.useState(null);
+ React.useState(null);
// =========================== Align ============================
const [mousePos, setMousePos] = React.useState<
@@ -672,6 +789,7 @@ export function generateTrigger(
if (hoverToShow) {
const onMouseEnterCallback = (event: React.MouseEvent) => {
+ clearSafeHover();
setMousePosByEvent(event);
};
@@ -692,6 +810,8 @@ export function generateTrigger(
);
onPopupMouseEnter = (event) => {
+ clearSafeHover();
+
// Only trigger re-open when popup is visible
if (
(mergedOpen || inMotion) &&
@@ -710,22 +830,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);
};
}
diff --git a/src/util.ts b/src/util.ts
index ba05902a..e79c03f3 100644
--- a/src/util.ts
+++ b/src/util.ts
@@ -52,8 +52,11 @@ export function collectScroller(ele: HTMLElement) {
while (current) {
const { overflowX, overflowY, overflow } =
- getWin(current).getComputedStyle(current);
- if ([overflowX, overflowY, overflow].some((o) => scrollStyle.includes(o))) {
+ getWin(current)?.getComputedStyle(current) || {};
+
+ if (
+ [overflowX, overflowY, overflow].some((o) => o && scrollStyle.includes(o))
+ ) {
scrollerList.push(current);
}
@@ -67,10 +70,17 @@ export function toNum(num: number, defaultValue = 1) {
return Number.isNaN(num) ? defaultValue : num;
}
-function getPxValue(val: string) {
- return toNum(parseFloat(val), 0);
+function getPxValue(val?: string) {
+ if (!val) {
+ return 0;
+ }
+ return toNum(Number.parseFloat(val), 0);
}
+export const clamp = (num: number, min: number, max: number) => {
+ return Math.min(Math.max(num, min), max);
+};
+
export interface VisibleArea {
left: number;
top: number;
@@ -119,7 +129,7 @@ export function getVisibleArea(
borderBottomWidth,
borderLeftWidth,
borderRightWidth,
- } = getWin(ele).getComputedStyle(ele);
+ } = getWin(ele)?.getComputedStyle(ele) || {};
const eleRect = ele.getBoundingClientRect();
const {
diff --git a/src/util/safeHover.ts b/src/util/safeHover.ts
new file mode 100644
index 00000000..464c3d7c
--- /dev/null
+++ b/src/util/safeHover.ts
@@ -0,0 +1,196 @@
+export type SafeHoverPoint = [x: number, y: number];
+
+export type SafeHoverRect = Pick<
+ DOMRect,
+ 'left' | 'right' | 'top' | 'bottom' | 'width' | 'height'
+>;
+
+export type SafeHoverSide = 'top' | 'bottom' | 'left' | 'right';
+
+export const isPointInPolygon = (
+ point: SafeHoverPoint,
+ polygon: SafeHoverPoint[],
+) => {
+ const [x, y] = point;
+ let isInside = false;
+
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
+ const [xi, yi] = polygon[i];
+ const [xj, yj] = polygon[j];
+ const intersect =
+ yi >= y !== yj >= y && x <= ((xj - xi) * (y - yi)) / (yj - yi) + xi;
+
+ if (intersect) {
+ isInside = !isInside;
+ }
+ }
+
+ return isInside;
+};
+
+export const isPointInRect = (point: SafeHoverPoint, rect: SafeHoverRect) => {
+ return (
+ point[0] >= rect.left &&
+ point[0] <= rect.right &&
+ point[1] >= rect.top &&
+ point[1] <= rect.bottom
+ );
+};
+
+export const getSafeHoverSide = (
+ targetRect: SafeHoverRect,
+ popupRect: SafeHoverRect,
+): SafeHoverSide | null => {
+ const gaps: { side: SafeHoverSide; value: number }[] = [
+ { side: 'top', value: targetRect.top - popupRect.bottom },
+ { side: 'bottom', value: popupRect.top - targetRect.bottom },
+ { side: 'left', value: targetRect.left - popupRect.right },
+ { side: 'right', value: popupRect.left - targetRect.right },
+ ];
+ const largestGap = gaps.reduce((prev, next) =>
+ next.value > prev.value ? next : prev,
+ );
+ return largestGap.value > 0 ? largestGap.side : null;
+};
+
+const isLeavePointTowardsPopup = (
+ side: SafeHoverSide,
+ leavePoint: SafeHoverPoint,
+ targetRect: SafeHoverRect,
+) => {
+ const [x, y] = leavePoint;
+ switch (side) {
+ case 'top':
+ return y <= targetRect.top + 1;
+ case 'bottom':
+ return y >= targetRect.bottom - 1;
+ case 'left':
+ return x <= targetRect.left + 1;
+ case 'right':
+ return x >= targetRect.right - 1;
+ }
+};
+
+const getSafeHoverGapPolygon = (
+ side: SafeHoverSide,
+ targetRect: SafeHoverRect,
+ popupRect: SafeHoverRect,
+ buffer: number,
+): SafeHoverPoint[] => {
+ const verticalRect =
+ popupRect.width > targetRect.width ? targetRect : popupRect;
+ const horizontalRect =
+ popupRect.height > targetRect.height ? targetRect : popupRect;
+ const left = verticalRect.left - buffer;
+ const right = verticalRect.right + buffer;
+ const top = horizontalRect.top - buffer;
+ const bottom = horizontalRect.bottom + buffer;
+ switch (side) {
+ case 'top':
+ return [
+ [left, popupRect.bottom - 1],
+ [left, targetRect.top + 1],
+ [right, targetRect.top + 1],
+ [right, popupRect.bottom - 1],
+ ];
+ case 'bottom':
+ return [
+ [left, targetRect.bottom - 1],
+ [left, popupRect.top + 1],
+ [right, popupRect.top + 1],
+ [right, targetRect.bottom - 1],
+ ];
+ case 'left':
+ return [
+ [popupRect.right - 1, top],
+ [popupRect.right - 1, bottom],
+ [targetRect.left + 1, bottom],
+ [targetRect.left + 1, top],
+ ];
+ case 'right':
+ return [
+ [targetRect.right - 1, top],
+ [targetRect.right - 1, bottom],
+ [popupRect.left + 1, bottom],
+ [popupRect.left + 1, top],
+ ];
+ }
+};
+
+const getSafeHoverIntentPolygon = (
+ side: SafeHoverSide,
+ leavePoint: SafeHoverPoint,
+ popupRect: SafeHoverRect,
+ buffer: number,
+): SafeHoverPoint[] => {
+ switch (side) {
+ case 'top':
+ return [
+ leavePoint,
+ [popupRect.left - buffer, popupRect.bottom + buffer],
+ [popupRect.right + buffer, popupRect.bottom + buffer],
+ ];
+ case 'bottom':
+ return [
+ leavePoint,
+ [popupRect.right + buffer, popupRect.top - buffer],
+ [popupRect.left - buffer, popupRect.top - buffer],
+ ];
+ case 'left':
+ return [
+ leavePoint,
+ [popupRect.right + buffer, popupRect.bottom + buffer],
+ [popupRect.right + buffer, popupRect.top - buffer],
+ ];
+ case 'right':
+ return [
+ leavePoint,
+ [popupRect.left - buffer, popupRect.top - buffer],
+ [popupRect.left - buffer, popupRect.bottom + buffer],
+ ];
+ }
+};
+
+export const getSafeHoverAreaPolygons = (
+ leavePoint: SafeHoverPoint,
+ targetRect: SafeHoverRect,
+ popupRect: SafeHoverRect,
+ buffer = 0.5,
+) => {
+ const side = getSafeHoverSide(targetRect, popupRect);
+
+ if (!side || !isLeavePointTowardsPopup(side, leavePoint, targetRect)) {
+ return [];
+ }
+ return [
+ getSafeHoverGapPolygon(side, targetRect, popupRect, buffer),
+ getSafeHoverIntentPolygon(side, leavePoint, popupRect, buffer),
+ ];
+};
+
+export const isPointInSafeHoverArea = (
+ point: SafeHoverPoint,
+ leavePoint: SafeHoverPoint,
+ targetRect: SafeHoverRect,
+ popupRect: SafeHoverRect,
+ buffer = 0.5,
+) => {
+ const safeHoverPolygons = getSafeHoverAreaPolygons(
+ leavePoint,
+ targetRect,
+ popupRect,
+ buffer,
+ );
+
+ if (!safeHoverPolygons.length) {
+ return false;
+ }
+
+ if (isPointInRect(point, targetRect) || isPointInRect(point, popupRect)) {
+ return true;
+ }
+
+ // The gap polygon keeps the straight corridor open; the intent polygon
+ // catches diagonal movement toward the popup edge.
+ return safeHoverPolygons.some((polygon) => isPointInPolygon(point, polygon));
+};
diff --git a/tests/arrow.test.jsx b/tests/arrow.test.jsx
index d49087ba..56dacad5 100644
--- a/tests/arrow.test.jsx
+++ b/tests/arrow.test.jsx
@@ -6,6 +6,7 @@ import {
spyElementPrototypes,
} from '@rc-component/util/lib/test/domHook';
import Trigger from '../src';
+import Arrow from '../src/Popup/Arrow';
describe('Trigger.Arrow', () => {
beforeAll(() => {
@@ -42,6 +43,22 @@ describe('Trigger.Arrow', () => {
);
});
+ it('uses default arrow options and position', () => {
+ render(
+ ,
+ );
+
+ expect(document.querySelector('.rc-trigger-popup-arrow')).toHaveStyle({
+ left: 0,
+ top: 0,
+ });
+ });
+
describe('direction', () => {
let divSpy;
let windowSpy;
diff --git a/tests/basic.test.jsx b/tests/basic.test.jsx
index 159c857f..16ee559d 100644
--- a/tests/basic.test.jsx
+++ b/tests/basic.test.jsx
@@ -136,6 +136,17 @@ describe('Trigger.Basic', () => {
});
describe('hover works', () => {
+ function mockRect(element, rect) {
+ element.getBoundingClientRect = jest.fn(() => ({
+ left: rect.left,
+ top: rect.top,
+ width: rect.width,
+ height: rect.height,
+ right: rect.left + rect.width,
+ bottom: rect.top + rect.height,
+ }));
+ }
+
it('mouse event', () => {
const { container } = render(
{
expect(isPopupHidden()).toBeFalsy();
});
+ it('keeps popup open while mouse moves through safe hover area', () => {
+ const { container } = render(
+ trigger}
+ >
+ hover
+ ,
+ );
+
+ const target = container.querySelector('.target');
+
+ fireEvent.mouseEnter(target, { clientX: 50, clientY: 10 });
+ act(() => jest.runAllTimers());
+
+ const popup = document.querySelector('.rc-trigger-popup');
+
+ mockRect(target, { left: 0, top: 0, width: 100, height: 20 });
+ mockRect(popup, { left: 20, top: 60, width: 60, height: 30 });
+
+ fireEvent.mouseLeave(target, { clientX: 50, clientY: 20 });
+ act(() => jest.advanceTimersByTime(50));
+
+ fireEvent.mouseMove(document, { clientX: 50, clientY: 40 });
+ act(() => jest.advanceTimersByTime(50));
+
+ expect(isPopupHidden()).toBeFalsy();
+
+ fireEvent.mouseEnter(popup, { clientX: 50, clientY: 60 });
+ act(() => jest.runAllTimers());
+
+ expect(isPopupHidden()).toBeFalsy();
+ });
+
+ it('tracks safe hover with mousemove instead of pointermove', () => {
+ const { container } = render(
+ trigger}
+ >
+ hover
+ ,
+ );
+
+ const target = container.querySelector('.target');
+
+ fireEvent.mouseEnter(target, { clientX: 50, clientY: 10 });
+ act(() => jest.runAllTimers());
+
+ const popup = document.querySelector('.rc-trigger-popup');
+
+ mockRect(target, { left: 0, top: 0, width: 100, height: 20 });
+ mockRect(popup, { left: 20, top: 60, width: 60, height: 30 });
+
+ fireEvent.mouseLeave(target, { clientX: 50, clientY: 20 });
+
+ target.getBoundingClientRect.mockClear();
+ popup.getBoundingClientRect.mockClear();
+
+ fireEvent.pointerMove(document, { clientX: 50, clientY: 40 });
+
+ expect(target.getBoundingClientRect).not.toHaveBeenCalled();
+ expect(popup.getBoundingClientRect).not.toHaveBeenCalled();
+
+ fireEvent.mouseMove(document, { clientX: 50, clientY: 40 });
+
+ expect(target.getBoundingClientRect).toHaveBeenCalledTimes(1);
+ expect(popup.getBoundingClientRect).toHaveBeenCalledTimes(1);
+ });
+
+ it('closes popup after mouse leaves safe hover area', () => {
+ const { container } = render(
+ trigger}
+ >
+ hover
+ ,
+ );
+
+ const target = container.querySelector('.target');
+
+ fireEvent.mouseEnter(target, { clientX: 50, clientY: 10 });
+ act(() => jest.runAllTimers());
+
+ const popup = document.querySelector('.rc-trigger-popup');
+
+ mockRect(target, { left: 0, top: 0, width: 100, height: 20 });
+ mockRect(popup, { left: 20, top: 60, width: 60, height: 30 });
+
+ fireEvent.mouseLeave(target, { clientX: 50, clientY: 20 });
+ act(() => jest.advanceTimersByTime(50));
+
+ fireEvent.mouseMove(document, { clientX: 50, clientY: 40 });
+ act(() => jest.advanceTimersByTime(80));
+
+ expect(isPopupHidden()).toBeFalsy();
+
+ fireEvent.mouseMove(document, { clientX: 150, clientY: 40 });
+ act(() => jest.advanceTimersByTime(100));
+
+ expect(isPopupHidden()).toBeTruthy();
+ });
+
+ it('cancels refresh close detection when mouse returns to safe hover area', () => {
+ const { container } = render(
+ trigger}
+ >
+ hover
+ ,
+ );
+
+ const target = container.querySelector('.target');
+
+ fireEvent.mouseEnter(target, { clientX: 50, clientY: 10 });
+ act(() => jest.runAllTimers());
+
+ const popup = document.querySelector('.rc-trigger-popup');
+
+ mockRect(target, { left: 0, top: 0, width: 100, height: 20 });
+ mockRect(popup, { left: 20, top: 60, width: 60, height: 30 });
+
+ fireEvent.mouseLeave(target, { clientX: 50, clientY: 20 });
+ fireEvent.mouseMove(document, { clientX: 150, clientY: 40 });
+ fireEvent.mouseMove(document, { clientX: 50, clientY: 40 });
+
+ act(() => jest.advanceTimersByTime(100));
+
+ expect(isPopupHidden()).toBeFalsy();
+ });
+
+ it('keeps pending refresh close detection while mouse remains unsafe', () => {
+ const { container } = render(
+ trigger}
+ >
+ hover
+ ,
+ );
+
+ const target = container.querySelector('.target');
+
+ fireEvent.mouseEnter(target, { clientX: 50, clientY: 10 });
+ act(() => jest.runAllTimers());
+
+ const popup = document.querySelector('.rc-trigger-popup');
+
+ mockRect(target, { left: 0, top: 0, width: 100, height: 20 });
+ mockRect(popup, { left: 20, top: 60, width: 60, height: 30 });
+
+ fireEvent.mouseLeave(target, { clientX: 50, clientY: 20 });
+ fireEvent.mouseMove(document, { clientX: 50, clientY: 150 });
+ fireEvent.mouseMove(document, { clientX: 50, clientY: 150 });
+
+ mockRect(popup, { left: 20, top: 60, width: 60, height: 200 });
+ act(() => jest.advanceTimersByTime(100));
+
+ expect(isPopupHidden()).toBeFalsy();
+ });
+
+ it('waits for mousemove to start refresh close detection after safe hover area disappears', () => {
+ const { container } = render(
+ trigger}
+ >
+ hover
+ ,
+ );
+
+ const target = container.querySelector('.target');
+
+ fireEvent.mouseEnter(target, { clientX: 50, clientY: 10 });
+ act(() => jest.runAllTimers());
+
+ const popup = document.querySelector('.rc-trigger-popup');
+
+ mockRect(target, { left: 0, top: 0, width: 100, height: 20 });
+ mockRect(popup, { left: 20, top: 60, width: 60, height: 30 });
+
+ fireEvent.mouseLeave(target, { clientX: 50, clientY: 20 });
+
+ mockRect(popup, { left: 10, top: 10, width: 60, height: 30 });
+ act(() => jest.advanceTimersByTime(150));
+
+ expect(isPopupHidden()).toBeFalsy();
+
+ fireEvent.mouseMove(document, { clientX: 150, clientY: 40 });
+ act(() => jest.advanceTimersByTime(100));
+
+ expect(isPopupHidden()).toBeTruthy();
+ });
+
+ it('keeps zero mouseLeaveDelay closing immediately', () => {
+ const { container } = render(
+ trigger}
+ >
+ hover
+ ,
+ );
+
+ const target = container.querySelector('.target');
+
+ fireEvent.mouseEnter(target, { clientX: 50, clientY: 10 });
+ act(() => jest.runAllTimers());
+
+ const popup = document.querySelector('.rc-trigger-popup');
+
+ mockRect(target, { left: 0, top: 0, width: 100, height: 20 });
+ mockRect(popup, { left: 20, top: 60, width: 60, height: 30 });
+
+ fireEvent.mouseLeave(target, { clientX: 50, clientY: 20 });
+ act(() => jest.runAllTimers());
+
+ expect(isPopupHidden()).toBeTruthy();
+ });
+
it('temporarily hides while disabled and restores without mouse leave', () => {
const onOpenChange = jest.fn();
const Demo = ({ disabled = false }) => (
@@ -963,7 +1203,8 @@ describe('Trigger.Basic', () => {
);
const unexpectedErrors = errorSpy.mock.calls.filter(
- ([message]) => !String(message).includes('Function components cannot be given refs'),
+ ([message]) =>
+ !String(message).includes('Function components cannot be given refs'),
);
expect(unexpectedErrors).toEqual([]);
errorSpy.mockRestore();
diff --git a/tests/flip.test.tsx b/tests/flip.test.tsx
index bc1efec6..f3d47acf 100644
--- a/tests/flip.test.tsx
+++ b/tests/flip.test.tsx
@@ -4,7 +4,7 @@ import { spyElementPrototypes } from '@rc-component/util/lib/test/domHook';
import * as React from 'react';
import type { AlignType, TriggerProps } from '../src';
import Trigger from '../src';
-import { getVisibleArea } from '../src/util';
+import { collectScroller, getVisibleArea } from '../src/util';
const flush = async () => {
for (let i = 0; i < 10; i += 1) {
@@ -366,6 +366,47 @@ describe('Trigger.Align', () => {
window.getComputedStyle = oriGetComputedStyle;
});
+ it('visible area skips document body and html elements', () => {
+ const initArea = {
+ left: 0,
+ right: 500,
+ top: 0,
+ bottom: 500,
+ };
+
+ expect(getVisibleArea(initArea)).toEqual(initArea);
+ expect(
+ getVisibleArea(initArea, [document.body, document.documentElement]),
+ ).toEqual(initArea);
+ });
+
+ it('handles elements without a document window', () => {
+ const detachedDocument = document.implementation.createHTMLDocument();
+ const scroller = detachedDocument.createElement('div');
+ const child = detachedDocument.createElement('div');
+
+ detachedDocument.body.appendChild(scroller);
+ scroller.appendChild(child);
+
+ expect(collectScroller(child)).toEqual([]);
+ expect(
+ getVisibleArea(
+ {
+ left: 0,
+ right: 500,
+ top: 0,
+ bottom: 500,
+ },
+ [scroller],
+ ),
+ ).toEqual({
+ left: 0,
+ right: 100,
+ top: 0,
+ bottom: 100,
+ });
+ });
+
// e.g. adjustY + shiftX may make popup out but push back in screen
// which should keep flip
/*
diff --git a/tests/safeHover.test.ts b/tests/safeHover.test.ts
new file mode 100644
index 00000000..7eaa4348
--- /dev/null
+++ b/tests/safeHover.test.ts
@@ -0,0 +1,160 @@
+import {
+ getSafeHoverAreaPolygons,
+ getSafeHoverSide,
+ isPointInSafeHoverArea,
+ type SafeHoverRect,
+} from '../src/util/safeHover';
+
+function rect(
+ left: number,
+ top: number,
+ width: number,
+ height: number,
+): SafeHoverRect {
+ return {
+ left,
+ top,
+ width,
+ height,
+ right: left + width,
+ bottom: top + height,
+ };
+}
+
+describe('safeHover util', () => {
+ it('detects popup side from separated rectangles', () => {
+ const target = rect(40, 40, 20, 20);
+
+ expect(getSafeHoverSide(target, rect(40, 0, 20, 20))).toBe('top');
+ expect(getSafeHoverSide(target, rect(40, 80, 20, 20))).toBe('bottom');
+ expect(getSafeHoverSide(target, rect(0, 40, 20, 20))).toBe('left');
+ expect(getSafeHoverSide(target, rect(80, 40, 20, 20))).toBe('right');
+ expect(getSafeHoverSide(target, rect(45, 45, 20, 20))).toBeNull();
+ });
+
+ it('keeps the vertical gap and diagonal intent safe', () => {
+ const target = rect(0, 0, 100, 20);
+ const popup = rect(20, 60, 60, 30);
+ const leavePoint: [number, number] = [50, 20];
+
+ expect(isPointInSafeHoverArea([50, 10], leavePoint, target, popup)).toBe(
+ true,
+ );
+ expect(isPointInSafeHoverArea([50, 70], leavePoint, target, popup)).toBe(
+ true,
+ );
+ expect(isPointInSafeHoverArea([50, 40], leavePoint, target, popup)).toBe(
+ true,
+ );
+ expect(isPointInSafeHoverArea([30, 50], leavePoint, target, popup)).toBe(
+ true,
+ );
+ expect(isPointInSafeHoverArea([150, 40], leavePoint, target, popup)).toBe(
+ false,
+ );
+ });
+
+ it('rejects leave points moving away from the popup', () => {
+ expect(
+ isPointInSafeHoverArea(
+ [50, 40],
+ [50, 0],
+ rect(0, 0, 100, 20),
+ rect(20, 60, 60, 30),
+ ),
+ ).toBe(false);
+ expect(
+ isPointInSafeHoverArea(
+ [50, 60],
+ [50, 100],
+ rect(0, 80, 100, 20),
+ rect(20, 10, 60, 30),
+ ),
+ ).toBe(false);
+ expect(
+ isPointInSafeHoverArea(
+ [60, 50],
+ [100, 50],
+ rect(80, 0, 20, 100),
+ rect(10, 20, 30, 60),
+ ),
+ ).toBe(false);
+ expect(
+ isPointInSafeHoverArea(
+ [40, 50],
+ [0, 50],
+ rect(0, 0, 20, 100),
+ rect(60, 20, 30, 60),
+ ),
+ ).toBe(false);
+ });
+
+ it('keeps horizontal gaps safe', () => {
+ expect(
+ isPointInSafeHoverArea(
+ [60, 50],
+ [80, 50],
+ rect(80, 0, 20, 100),
+ rect(10, 20, 30, 60),
+ ),
+ ).toBe(true);
+ expect(
+ isPointInSafeHoverArea(
+ [40, 50],
+ [20, 50],
+ rect(0, 0, 20, 100),
+ rect(60, 20, 30, 60),
+ ),
+ ).toBe(true);
+ });
+
+ it('keeps top gap and diagonal intent safe', () => {
+ const target = rect(40, 80, 20, 20);
+ const popup = rect(0, 10, 100, 30);
+ const leavePoint: [number, number] = [50, 80];
+
+ expect(isPointInSafeHoverArea([50, 60], leavePoint, target, popup)).toBe(
+ true,
+ );
+ expect(isPointInSafeHoverArea([25, 55], leavePoint, target, popup)).toBe(
+ true,
+ );
+ });
+
+ it('keeps horizontal diagonal intent safe', () => {
+ expect(
+ isPointInSafeHoverArea(
+ [55, 25],
+ [80, 50],
+ rect(80, 40, 20, 20),
+ rect(10, 0, 30, 100),
+ ),
+ ).toBe(true);
+ expect(
+ isPointInSafeHoverArea(
+ [45, 25],
+ [20, 50],
+ rect(0, 40, 20, 20),
+ rect(60, 0, 30, 100),
+ ),
+ ).toBe(true);
+ });
+
+ it('supports custom safe hover buffer', () => {
+ expect(
+ getSafeHoverAreaPolygons(
+ [50, 20],
+ rect(0, 0, 100, 20),
+ rect(20, 60, 60, 30),
+ ),
+ ).toHaveLength(2);
+ expect(
+ getSafeHoverAreaPolygons(
+ [50, 20],
+ rect(0, 0, 100, 20),
+ rect(20, 60, 60, 30),
+ 4,
+ ),
+ ).toHaveLength(2);
+ });
+});
diff --git a/tests/useOffsetStyle.test.tsx b/tests/useOffsetStyle.test.tsx
new file mode 100644
index 00000000..2de789ad
--- /dev/null
+++ b/tests/useOffsetStyle.test.tsx
@@ -0,0 +1,29 @@
+import { renderHook } from '@testing-library/react';
+import useOffsetStyle from '../src/hooks/useOffsetStyle';
+
+describe('useOffsetStyle', () => {
+ it('uses right and bottom offsets with dynamic inset alignment', () => {
+ const { result } = renderHook(() =>
+ useOffsetStyle(
+ false,
+ true,
+ true,
+ {
+ points: ['br', 'tr'],
+ dynamicInset: true,
+ },
+ 12,
+ 34,
+ 56,
+ 78,
+ ),
+ );
+
+ expect(result.current).toEqual({
+ left: 'auto',
+ top: 'auto',
+ right: 12,
+ bottom: 34,
+ });
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
index 1d3c6ba5..dacb4dc4 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -8,24 +8,12 @@
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"paths": {
- "@/*": [
- "./src/*"
- ],
- "@@/*": [
- "./.dumi/tmp/*"
- ],
- "@rc-component/trigger": [
- "./src/index.tsx"
- ],
- "@rc-component/trigger/es": [
- "./src"
- ],
- "@rc-component/trigger/es/*": [
- "./src/*"
- ],
- "@rc-component/trigger/assets/*": [
- "./assets/*"
- ]
+ "@/*": ["./src/*"],
+ "@@/*": ["./.dumi/tmp/*"],
+ "@rc-component/trigger": ["./src/index.tsx"],
+ "@rc-component/trigger/es": ["./src"],
+ "@rc-component/trigger/es/*": ["./src/*"],
+ "@rc-component/trigger/assets/*": ["./assets/*"]
},
"strict": false,
"module": "ESNext"
@@ -38,9 +26,5 @@
"tests",
"docs/examples"
],
- "exclude": [
- "docs-dist",
- "lib",
- "es"
- ]
+ "exclude": ["docs-dist", "lib", "es"]
}
diff --git a/typings.d.ts b/typings.d.ts
new file mode 100644
index 00000000..1ea39606
--- /dev/null
+++ b/typings.d.ts
@@ -0,0 +1 @@
+declare module '*.less';