diff --git a/.dumirc.ts b/.dumirc.ts
new file mode 100644
index 0000000..bd80941
--- /dev/null
+++ b/.dumirc.ts
@@ -0,0 +1,23 @@
+import { defineConfig } from 'dumi';
+import path from 'path';
+
+export default defineConfig({
+ alias: {
+ 'rc-trigger$': path.resolve('src'),
+ 'rc-trigger/es': path.resolve('src'),
+ },
+ mfsu: false,
+ favicons: ['https://avatars0.githubusercontent.com/u/9441414?s=200&v=4'],
+ themeConfig: {
+ name: 'Trigger',
+ logo: 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4',
+ },
+ styles: [
+ `
+ .dumi-default-previewer-demo {
+ position: relative;
+ min-height: 300px;
+ }
+ `,
+ ]
+});
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..604c94e
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,9 @@
+# top-most EditorConfig file
+root = true
+
+# Unix-style newlines with a newline ending every file
+[*.{js,css}]
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 2
diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 0000000..7e19ab3
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,14 @@
+module.exports = {
+ extends: [require.resolve('@umijs/fabric/dist/eslint')],
+ rules: {
+ 'default-case': 0,
+ 'import/no-extraneous-dependencies': 0,
+ 'react-hooks/exhaustive-deps': 0,
+ 'react/no-find-dom-node': 0,
+ 'react/no-did-update-set-state': 0,
+ 'react/no-unused-state': 1,
+ 'react/sort-comp': 0,
+ 'jsx-a11y/label-has-for': 0,
+ 'jsx-a11y/label-has-associated-control': 0,
+ },
+};
diff --git a/.fatherrc.js b/.fatherrc.js
new file mode 100644
index 0000000..4ddbafd
--- /dev/null
+++ b/.fatherrc.js
@@ -0,0 +1,5 @@
+import { defineConfig } from 'father';
+
+export default defineConfig({
+ plugins: ['@rc-component/father-plugin'],
+});
\ No newline at end of file
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..23a5fb8
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,30 @@
+version: 2
+updates:
+- package-ecosystem: npm
+ directory: "/"
+ schedule:
+ interval: daily
+ time: "21:00"
+ open-pull-requests-limit: 10
+ ignore:
+ - dependency-name: np
+ versions:
+ - 7.2.0
+ - 7.3.0
+ - 7.4.0
+ - dependency-name: "@types/react-dom"
+ versions:
+ - 17.0.0
+ - 17.0.1
+ - 17.0.2
+ - dependency-name: "@types/react"
+ versions:
+ - 17.0.0
+ - 17.0.1
+ - 17.0.2
+ - 17.0.3
+ - dependency-name: typescript
+ versions:
+ - 4.1.3
+ - 4.1.4
+ - 4.1.5
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..67f7884
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,41 @@
+name: "CodeQL"
+
+on:
+ push:
+ branches: [ "master" ]
+ pull_request:
+ branches: [ "master" ]
+ schedule:
+ - cron: "40 12 * * 0"
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [ javascript ]
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v2
+ with:
+ languages: ${{ matrix.language }}
+ queries: +security-and-quality
+
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v2
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v2
+ with:
+ category: "/language:${{ matrix.language }}"
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 0000000..a23ad09
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,114 @@
+name: CI
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ branches: [ master ]
+
+jobs:
+ setup:
+ runs-on: ubuntu-latest
+ steps:
+ - name: checkout
+ uses: actions/checkout@master
+
+ - uses: actions/setup-node@v1
+ with:
+ node-version: '16'
+
+ - name: cache package-lock.json
+ uses: actions/cache@v2
+ with:
+ path: package-temp-dir
+ key: lock-${{ github.sha }}
+
+ - name: create package-lock.json
+ run: npm i --package-lock-only
+
+ - name: hack for singe file
+ run: |
+ if [ ! -d "package-temp-dir" ]; then
+ mkdir package-temp-dir
+ fi
+ cp package-lock.json package-temp-dir
+
+ - name: cache node_modules
+ id: node_modules_cache_id
+ uses: actions/cache@v2
+ with:
+ path: node_modules
+ key: node_modules-${{ hashFiles('**/package-temp-dir/package-lock.json') }}
+
+ - name: install
+ if: steps.node_modules_cache_id.outputs.cache-hit != 'true'
+ run: npm i
+
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - name: checkout
+ uses: actions/checkout@master
+
+ - name: restore cache from package-lock.json
+ uses: actions/cache@v2
+ with:
+ path: package-temp-dir
+ key: lock-${{ github.sha }}
+
+ - name: restore cache from node_modules
+ uses: actions/cache@v2
+ with:
+ path: node_modules
+ key: node_modules-${{ hashFiles('**/package-temp-dir/package-lock.json') }}
+
+ - name: lint
+ run: npm run lint
+
+ needs: setup
+
+ compile:
+ runs-on: ubuntu-latest
+ steps:
+ - name: checkout
+ uses: actions/checkout@master
+
+ - name: restore cache from package-lock.json
+ uses: actions/cache@v2
+ with:
+ path: package-temp-dir
+ key: lock-${{ github.sha }}
+
+ - name: restore cache from node_modules
+ uses: actions/cache@v2
+ with:
+ path: node_modules
+ key: node_modules-${{ hashFiles('**/package-temp-dir/package-lock.json') }}
+
+ - name: compile
+ run: npm run compile
+
+ needs: setup
+
+ coverage:
+ runs-on: ubuntu-latest
+ steps:
+ - name: checkout
+ uses: actions/checkout@master
+
+ - name: restore cache from package-lock.json
+ uses: actions/cache@v2
+ with:
+ path: package-temp-dir
+ key: lock-${{ github.sha }}
+
+ - name: restore cache from node_modules
+ uses: actions/cache@v2
+ with:
+ path: node_modules
+ key: node_modules-${{ hashFiles('**/package-temp-dir/package-lock.json') }}
+
+ - name: coverage
+ run: npm run coverage && bash <(curl -s https://codecov.io/bash)
+
+ needs: setup
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..65899cf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,40 @@
+.storybook
+*.iml
+*.log
+.idea
+.ipr
+.iws
+*~
+~*
+*.diff
+*.patch
+*.bak
+.DS_Store
+Thumbs.db
+.project
+.*proj
+.svn
+*.swp
+*.swo
+*.pyc
+*.pyo
+node_modules
+.cache
+*.css
+build
+lib
+es
+coverage
+yarn.lock
+package-lock.json
+
+# dumi
+.umi
+.umi-production
+.umi-test
+.docs
+
+
+# dumi
+.dumi/tmp
+.dumi/tmp-production
\ No newline at end of file
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..fb49bf5
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,8 @@
+{
+ "endOfLine": "lf",
+ "semi": true,
+ "singleQuote": true,
+ "tabWidth": 2,
+ "trailingComma": "all",
+ "jsxSingleQuote": false
+}
diff --git a/HISTORY.md b/HISTORY.md
new file mode 100644
index 0000000..7ee63c8
--- /dev/null
+++ b/HISTORY.md
@@ -0,0 +1,58 @@
+# History
+----
+
+## 4.1.0 / 2020-05-08
+
+- upgrade rc-animate to `3.x`
+
+## 2.5.0 / 2018-06-05
+
+- support `alignPoint`
+
+## 2.1.0 / 2017-10-16
+
+- add action `contextMenu`
+
+## 2.0.0 / 2017-09-25
+
+- support React 16
+
+## 1.11.0 / 2017-06-07
+
+- add es
+
+## 1.9.0 / 2017-02-27
+
+- add getDocument prop
+
+## 1.8.2 / 2017-02-24
+
+- change default container to absolute to fix scrollbar change problem
+
+## 1.7.0 / 2016-07-18
+
+- use getContainerRenderMixin from 'rc-util'
+
+## 1.6.0 / 2016-05-26
+
+- support popup as function
+
+## 1.5.0 / 2016-05-26
+
+- add forcePopupAlign method
+
+## 1.4.0 / 2016-04-06
+
+- support onPopupAlign
+
+## 1.3.0 / 2016-03-25
+
+- support mask/maskTransitionName/zIndex
+
+## 1.2.0 / 2016-03-01
+
+- add showAction/hideAction
+
+## 1.1.0 / 2016-01-06
+
+- add root trigger node as parameter of getPopupContainer
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..fbf368a
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,20 @@
+The MIT License (MIT)
+Copyright (c) 2015-present Alipay.com, https://www.alipay.com/
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e479e56
--- /dev/null
+++ b/README.md
@@ -0,0 +1,258 @@
+# @rc-component/trigger
+
+React Trigger Component
+
+[![NPM version][npm-image]][npm-url]
+[![npm download][download-image]][download-url]
+[![build status][github-actions-image]][github-actions-url]
+[![Test coverage][codecov-image]][codecov-url]
+[![bundle size][bundlephobia-image]][bundlephobia-url]
+[![dumi][dumi-image]][dumi-url]
+
+[npm-image]: http://img.shields.io/npm/v/rc-checkbox.svg?style=flat-square
+[npm-url]: http://npmjs.org/package/rc-checkbox
+[github-actions-image]: https://github.com/react-component/checkbox/workflows/CI/badge.svg
+[github-actions-url]: https://github.com/react-component/checkbox/actions
+[codecov-image]: https://img.shields.io/codecov/c/github/react-component/checkbox/master.svg?style=flat-square
+[codecov-url]: https://codecov.io/gh/react-component/checkbox/branch/master
+[david-url]: https://david-dm.org/react-component/checkbox
+[david-image]: https://david-dm.org/react-component/checkbox/status.svg?style=flat-square
+[david-dev-url]: https://david-dm.org/react-component/checkbox?type=dev
+[david-dev-image]: https://david-dm.org/react-component/checkbox/dev-status.svg?style=flat-square
+[download-image]: https://img.shields.io/npm/dm/rc-checkbox.svg?style=flat-square
+[download-url]: https://npmjs.org/package/rc-checkbox
+[bundlephobia-url]: https://bundlephobia.com/result?p=rc-checkbox
+[bundlephobia-image]: https://badgen.net/bundlephobia/minzip/rc-checkbox
+[dumi-image]: https://img.shields.io/badge/docs%20by-dumi-blue?style=flat-square
+[dumi-url]: https://github.com/umijs/dumi
+
+## Install
+
+[![@rc-component/trigger](https://nodei.co/npm/@rc-component/trigger.png)](https://npmjs.org/package/@rc-component/trigger)
+
+## Usage
+
+Include the default [styling](https://github.com/react-component/trigger/blob/master/assets/index.less#L4:L11) and then:
+
+```js
+import React from 'react';
+import ReactDOM from 'react-dom';
+import Trigger from '@rc-component/trigger';
+
+ReactDOM.render((
+ popup}
+ popupAlign={{
+ points: ['tl', 'bl'],
+ offset: [0, 3]
+ }}
+ >
+ hover
+
+), container);
+```
+
+## Compatibility
+
+| [](http://godban.github.io/browsers-support-badges/)
IE / Edge | [](http://godban.github.io/browsers-support-badges/)
Firefox | [](http://godban.github.io/browsers-support-badges/)
Chrome | [](http://godban.github.io/browsers-support-badges/)
Safari | [](http://godban.github.io/browsers-support-badges/)
Electron |
+| --- | --- | --- | --- | --- |
+| IE11, Edge | last 2 versions | last 2 versions | last 2 versions | last 2 versions |
+
+## Example
+
+http://localhost:9001
+
+## Development
+
+```
+npm install
+npm start
+```
+
+## API
+
+### props
+
+
+
+
+ name |
+ type |
+ default |
+ description |
+
+
+
+
+ alignPoint |
+ bool |
+ false |
+ Popup will align with mouse position (support action of 'click', 'hover' and 'contextMenu') |
+
+
+ popupClassName |
+ string |
+ |
+ additional className added to popup |
+
+
+ forceRender |
+ boolean |
+ false |
+ whether render popup before first show |
+
+
+ destroyPopupOnHide |
+ boolean |
+ false |
+ whether destroy popup when hide |
+
+
+ getPopupClassNameFromAlign |
+ getPopupClassNameFromAlign(align: Object):String |
+ |
+ additional className added to popup according to align |
+
+
+ action |
+ string[] |
+ ['hover'] |
+ which actions cause popup shown. enum of 'hover','click','focus','contextMenu' |
+
+
+ mouseEnterDelay |
+ number |
+ 0 |
+ delay time to show when mouse enter. unit: s. |
+
+
+ mouseLeaveDelay |
+ number |
+ 0.1 |
+ delay time to hide when mouse leave. unit: s. |
+
+
+ popupStyle |
+ Object |
+ |
+ additional style of popup |
+
+
+ prefixCls |
+ String |
+ rc-trigger-popup |
+ prefix class name |
+
+
+ popupTransitionName |
+ String|Object |
+ |
+ https://github.com/react-component/animate |
+
+
+ maskTransitionName |
+ String|Object |
+ |
+ https://github.com/react-component/animate |
+
+
+ onPopupVisibleChange |
+ Function |
+ |
+ call when popup visible is changed |
+
+
+ mask |
+ boolean |
+ false |
+ whether to support mask |
+
+
+ maskClosable |
+ boolean |
+ true |
+ whether to support click mask to hide |
+
+
+ popupVisible |
+ boolean |
+ |
+ whether popup is visible |
+
+
+ zIndex |
+ number |
+ |
+ popup's zIndex |
+
+
+ defaultPopupVisible |
+ boolean |
+ |
+ whether popup is visible initially |
+
+
+ popupAlign |
+ Object: alignConfig of [dom-align](https://github.com/yiminghe/dom-align) |
+ |
+ popup 's align config |
+
+
+ onPopupAlign |
+ function(popupDomNode, align) |
+ |
+ callback when popup node is aligned |
+
+
+ popup |
+ React.Element | function() => React.Element |
+ |
+ popup content |
+
+
+ getPopupContainer |
+ getPopupContainer(): HTMLElement |
+ |
+ function returning html node which will act as popup container |
+
+
+ getDocument |
+ getDocument(): HTMLElement |
+ |
+ function returning document node which will be attached click event to close trigger |
+
+
+ popupPlacement |
+ string |
+ |
+ use preset popup align config from builtinPlacements, can be merged by popupAlign prop |
+
+
+ builtinPlacements |
+ object |
+ |
+ builtin placement align map. used by placement prop |
+
+
+ stretch |
+ string |
+ |
+ Let popup div stretch with trigger element. enums of 'width', 'minWidth', 'height', 'minHeight'. (You can also mixed with 'height minWidth') |
+
+
+
+
+
+## Test Case
+
+```
+npm test
+npm run coverage
+```
+
+open coverage/ dir
+
+## License
+
+rc-trigger is released under the MIT license.
diff --git a/assets/index.less b/assets/index.less
new file mode 100644
index 0000000..e13a125
--- /dev/null
+++ b/assets/index.less
@@ -0,0 +1,79 @@
+@triggerPrefixCls: rc-trigger-popup;
+
+.@{triggerPrefixCls} {
+ position: absolute;
+ top: -9999px;
+ left: -9999px;
+ z-index: 1050;
+
+ &-hidden {
+ display: none;
+ }
+
+ .effect() {
+ animation-duration: 0.3s;
+ animation-fill-mode: both;
+ }
+
+ &-zoom-enter,
+ &-zoom-appear {
+ opacity: 0;
+ animation-play-state: paused;
+ animation-timing-function: cubic-bezier(0.18, 0.89, 0.32, 1.28);
+ .effect();
+ }
+
+ &-zoom-leave {
+ .effect();
+ animation-play-state: paused;
+ animation-timing-function: cubic-bezier(0.6, -0.3, 0.74, 0.05);
+ }
+
+ &-zoom-enter&-zoom-enter-active,
+ &-zoom-appear&-zoom-appear-active {
+ animation-name: rcTriggerZoomIn;
+ animation-play-state: running;
+ }
+
+ &-zoom-leave&-zoom-leave-active {
+ animation-name: rcTriggerZoomOut;
+ animation-play-state: running;
+ }
+
+ &-arrow {
+ z-index: 1;
+ width: 0px;
+ height: 0px;
+ background: #000;
+ border-radius: 100vw;
+ box-shadow: 0 0 0 3px black;
+ }
+
+ @keyframes rcTriggerZoomIn {
+ 0% {
+ transform: scale(0, 0);
+ transform-origin: var(--arrow-x, 50%) var(--arrow-y, 50%);
+ opacity: 0;
+ }
+ 100% {
+ transform: scale(1, 1);
+ transform-origin: var(--arrow-x, 50%) var(--arrow-y, 50%);
+ opacity: 1;
+ }
+ }
+ @keyframes rcTriggerZoomOut {
+ 0% {
+ transform: scale(1, 1);
+ transform-origin: var(--arrow-x, 50%) var(--arrow-y, 50%);
+ opacity: 1;
+ }
+ 100% {
+ transform: scale(0, 0);
+ transform-origin: var(--arrow-x, 50%) var(--arrow-y, 50%);
+ opacity: 0;
+ }
+ }
+}
+
+@import './index/Mask';
+@import './index/Mobile';
diff --git a/assets/index/Mask.less b/assets/index/Mask.less
new file mode 100644
index 0000000..81692e6
--- /dev/null
+++ b/assets/index/Mask.less
@@ -0,0 +1,63 @@
+.@{triggerPrefixCls} {
+ &-mask {
+ position: fixed;
+ top: 0;
+ right: 0;
+ left: 0;
+ bottom: 0;
+ background-color: rgb(55, 55, 55);
+ background-color: rgba(55, 55, 55, 0.6);
+ height: 100%;
+ filter: alpha(opacity=50);
+ z-index: 1050;
+
+ &-hidden {
+ display: none;
+ }
+ }
+
+ .fade-effect() {
+ animation-duration: 0.3s;
+ animation-fill-mode: both;
+ animation-timing-function: cubic-bezier(0.55, 0, 0.55, 0.2);
+ }
+
+ &-fade-enter,&-fade-appear {
+ opacity: 0;
+ .fade-effect();
+ animation-play-state: paused;
+ }
+
+ &-fade-leave {
+ .fade-effect();
+ animation-play-state: paused;
+ }
+
+ &-fade-enter&-fade-enter-active,&-fade-appear&-fade-appear-active {
+ animation-name: rcTriggerMaskFadeIn;
+ animation-play-state: running;
+ }
+
+ &-fade-leave&-fade-leave-active {
+ animation-name: rcDialogFadeOut;
+ animation-play-state: running;
+ }
+
+ @keyframes rcTriggerMaskFadeIn {
+ 0% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+ }
+
+ @keyframes rcDialogFadeOut {
+ 0% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ }
+ }
+}
diff --git a/assets/index/Mobile.less b/assets/index/Mobile.less
new file mode 100644
index 0000000..3c302ca
--- /dev/null
+++ b/assets/index/Mobile.less
@@ -0,0 +1,25 @@
+.@{triggerPrefixCls} {
+ &-mobile {
+ transition: all 0.3s;
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ top: auto;
+
+ &-fade {
+ &-appear,
+ &-enter {
+ &-start {
+ transform: translateY(100%);
+ }
+ }
+
+ &-leave {
+ &-active {
+ transform: translateY(100%);
+ }
+ }
+ }
+ }
+}
diff --git a/docs/demos/body-overflow.md b/docs/demos/body-overflow.md
new file mode 100644
index 0000000..6f6f9c4
--- /dev/null
+++ b/docs/demos/body-overflow.md
@@ -0,0 +1,8 @@
+---
+title: Body Overflow
+nav:
+ title: Demo
+ path: /demo
+---
+
+
\ No newline at end of file
diff --git a/docs/demos/case.md b/docs/demos/case.md
new file mode 100644
index 0000000..053a1bc
--- /dev/null
+++ b/docs/demos/case.md
@@ -0,0 +1,8 @@
+---
+title: Case
+nav:
+ title: Demo
+ path: /demo
+---
+
+
\ No newline at end of file
diff --git a/docs/demos/click-nested.md b/docs/demos/click-nested.md
new file mode 100644
index 0000000..c67236d
--- /dev/null
+++ b/docs/demos/click-nested.md
@@ -0,0 +1,8 @@
+---
+title: Click Nested
+nav:
+ title: Demo
+ path: /demo
+---
+
+
\ No newline at end of file
diff --git a/docs/demos/clip.md b/docs/demos/clip.md
new file mode 100644
index 0000000..e2798ee
--- /dev/null
+++ b/docs/demos/clip.md
@@ -0,0 +1,8 @@
+---
+title: Clip
+nav:
+ title: Demo
+ path: /demo
+---
+
+
\ No newline at end of file
diff --git a/docs/demos/container.md b/docs/demos/container.md
new file mode 100644
index 0000000..a4860cb
--- /dev/null
+++ b/docs/demos/container.md
@@ -0,0 +1,8 @@
+---
+title: Container
+nav:
+ title: Demo
+ path: /demo
+---
+
+
\ No newline at end of file
diff --git a/docs/demos/inside.md b/docs/demos/inside.md
new file mode 100644
index 0000000..a9853c3
--- /dev/null
+++ b/docs/demos/inside.md
@@ -0,0 +1,8 @@
+---
+title: Inside
+nav:
+ title: Demo
+ path: /demo
+---
+
+
\ No newline at end of file
diff --git a/docs/demos/large-popup.md b/docs/demos/large-popup.md
new file mode 100644
index 0000000..048c77a
--- /dev/null
+++ b/docs/demos/large-popup.md
@@ -0,0 +1,8 @@
+---
+title: Large Popup
+nav:
+ title: Demo
+ path: /demo
+---
+
+
\ No newline at end of file
diff --git a/docs/demos/nested.md b/docs/demos/nested.md
new file mode 100644
index 0000000..5daf43b
--- /dev/null
+++ b/docs/demos/nested.md
@@ -0,0 +1,8 @@
+---
+title: Nested
+nav:
+ title: Demo
+ path: /demo
+---
+
+
\ No newline at end of file
diff --git a/docs/demos/point.md b/docs/demos/point.md
new file mode 100644
index 0000000..073a89b
--- /dev/null
+++ b/docs/demos/point.md
@@ -0,0 +1,8 @@
+---
+title: Point
+nav:
+ title: Demo
+ path: /demo
+---
+
+
\ No newline at end of file
diff --git a/docs/demos/shadow.md b/docs/demos/shadow.md
new file mode 100644
index 0000000..379812b
--- /dev/null
+++ b/docs/demos/shadow.md
@@ -0,0 +1,8 @@
+---
+title: Shadow
+nav:
+ title: Demo
+ path: /demo
+---
+
+
\ No newline at end of file
diff --git a/docs/demos/simple.md b/docs/demos/simple.md
new file mode 100644
index 0000000..dbb7c35
--- /dev/null
+++ b/docs/demos/simple.md
@@ -0,0 +1,8 @@
+---
+title: Simple
+nav:
+ title: Demo
+ path: /demo
+---
+
+
\ No newline at end of file
diff --git a/docs/demos/static-scroll.md b/docs/demos/static-scroll.md
new file mode 100644
index 0000000..14a1846
--- /dev/null
+++ b/docs/demos/static-scroll.md
@@ -0,0 +1,8 @@
+---
+title: Static Scroll
+nav:
+ title: Demo
+ path: /demo
+---
+
+
diff --git a/docs/demos/visible-fallback.md b/docs/demos/visible-fallback.md
new file mode 100644
index 0000000..cb5a8f2
--- /dev/null
+++ b/docs/demos/visible-fallback.md
@@ -0,0 +1,8 @@
+---
+title: Visible Fallback
+nav:
+ title: Demo
+ path: /demo
+---
+
+
\ No newline at end of file
diff --git a/docs/examples/body-overflow.tsx b/docs/examples/body-overflow.tsx
new file mode 100644
index 0000000..744e86a
--- /dev/null
+++ b/docs/examples/body-overflow.tsx
@@ -0,0 +1,248 @@
+/* eslint no-console:0 */
+import Trigger from 'rc-trigger';
+import React from 'react';
+import { createPortal } from 'react-dom';
+import '../../assets/index.less';
+
+const PortalDemo = () => {
+ return createPortal(
+
+ PortalNode
+
,
+ document.body,
+ );
+};
+
+export default () => {
+ const [open, setOpen] = React.useState(false);
+ const [open1, setOpen1] = React.useState(false);
+ const [open2, setOpen2] = React.useState(false);
+ const [open3, setOpen3] = React.useState(false);
+ return (
+
+
+
+ {
+ console.log('Visible Change:', next);
+ setOpen(next);
+ }}
+ popupTransitionName="rc-trigger-popup-zoom"
+ popup={
+
+
+
+
+
+ }
+ // popupVisible
+ popupStyle={{ boxShadow: '0 0 5px red' }}
+ popupAlign={{
+ points: ['tc', 'bc'],
+ overflow: {
+ shiftX: 50,
+ adjustY: true,
+ },
+ htmlRegion: 'scroll',
+ }}
+ >
+
+
+
+ {
+ console.log('Visible Change:', next);
+ setOpen1(next);
+ }}
+ popupTransitionName="rc-trigger-popup-zoom"
+ popup={
+
+
+
+ }
+ // popupVisible
+ popupStyle={{ boxShadow: '0 0 5px red' }}
+ popupAlign={{
+ points: ['tc', 'bc'],
+ overflow: {
+ shiftX: 50,
+ adjustY: true,
+ },
+ htmlRegion: 'scroll',
+ }}
+ >
+
+ Target Click
+
+
+
+ {
+ console.log('Visible Change:', next);
+ setOpen2(next);
+ }}
+ popupTransitionName="rc-trigger-popup-zoom"
+ popup={
+
+ Target ContextMenu1
+
+ }
+ popupStyle={{ boxShadow: '0 0 5px red' }}
+ popupAlign={{
+ points: ['tc', 'bc'],
+ overflow: {
+ shiftX: 50,
+ adjustY: true,
+ },
+ htmlRegion: 'scroll',
+ }}
+ >
+
+ Target ContextMenu1
+
+
+
+ {
+ console.log('Visible Change:', next);
+ setOpen3(next);
+ }}
+ popupTransitionName="rc-trigger-popup-zoom"
+ popup={
+
+ Target ContextMenu2
+
+ }
+ popupStyle={{ boxShadow: '0 0 5px red' }}
+ popupAlign={{
+ points: ['tc', 'bc'],
+ overflow: {
+ shiftX: 50,
+ adjustY: true,
+ },
+ htmlRegion: 'scroll',
+ }}
+ >
+
+ Target ContextMenu2
+
+
+
+ );
+};
diff --git a/docs/examples/case.less b/docs/examples/case.less
new file mode 100644
index 0000000..a1012d6
--- /dev/null
+++ b/docs/examples/case.less
@@ -0,0 +1,109 @@
+// .rc-trigger-popup-placement-right {
+// border-width: 10px!important;
+// }
+
+// ======================= Popup =======================
+.case-motion {
+ transform-origin: 50% 50%;
+
+ animation-duration: 0.3s;
+ animation-timing-function: cubic-bezier(0.18, 0.89, 0.32, 1.28);
+ animation-fill-mode: both;
+
+ &::after {
+ content: 'Animating...';
+ position: absolute;
+ bottom: -3em;
+ }
+
+ &-appear,
+ &-enter {
+ animation-play-state: paused;
+
+ &-active {
+ animation-name: case-zoom-in;
+ animation-play-state: running;
+ }
+ }
+
+ &-leave {
+ animation-play-state: paused;
+
+ &-active {
+ animation-name: case-zoom-out;
+ animation-play-state: running;
+ }
+ }
+}
+
+@keyframes case-zoom-in {
+ 0% {
+ opacity: 0;
+ transform: scale(0);
+ }
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@keyframes case-zoom-out {
+ 0% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ 100% {
+ opacity: 0;
+ transform: scale(1.2);
+ }
+}
+
+// ======================= Mask =======================
+.mask-motion {
+ animation-duration: 0.3s;
+ animation-fill-mode: both;
+ position: fixed;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.3);
+
+ &-appear,
+ &-enter {
+ animation-play-state: paused;
+ opacity: 0;
+
+ &-active {
+ animation-name: mask-zoom-in;
+ animation-play-state: running;
+ }
+ }
+
+ &-leave {
+ animation-play-state: paused;
+
+ &-active {
+ animation-name: mask-zoom-out;
+ animation-play-state: running;
+ }
+ }
+}
+
+@keyframes mask-zoom-in {
+ 0% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+}
+
+@keyframes mask-zoom-out {
+ 0% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ }
+}
diff --git a/docs/examples/case.tsx b/docs/examples/case.tsx
new file mode 100644
index 0000000..2d24a8e
--- /dev/null
+++ b/docs/examples/case.tsx
@@ -0,0 +1,241 @@
+/* eslint no-console:0 */
+
+import React from 'react';
+import type { CSSMotionProps } from 'rc-motion';
+import type { BuildInPlacements } from 'rc-trigger';
+import Trigger from 'rc-trigger';
+import './case.less';
+
+const builtinPlacements: BuildInPlacements = {
+ left: {
+ points: ['cr', 'cl'],
+ },
+ right: {
+ points: ['cl', 'cr'],
+ },
+ top: {
+ points: ['bc', 'tc'],
+ },
+ bottom: {
+ points: ['tc', 'bc'],
+ },
+ topLeft: {
+ points: ['bl', 'tl'],
+ },
+ topRight: {
+ points: ['br', 'tr'],
+ },
+ bottomRight: {
+ points: ['tr', 'br'],
+ },
+ bottomLeft: {
+ points: ['tl', 'bl'],
+ },
+};
+
+const Motion: CSSMotionProps = {
+ motionName: 'case-motion',
+};
+
+const MaskMotion: CSSMotionProps = {
+ motionName: 'mask-motion',
+};
+
+function useControl(valuePropName: string, defaultValue: T): [T, any] {
+ const [value, setValue] = React.useState(defaultValue);
+
+ return [
+ value,
+ {
+ value,
+ checked: value,
+ onChange({ target }) {
+ setValue(target[valuePropName]);
+ },
+ },
+ ];
+}
+
+const LabelItem: React.FC<{
+ title: React.ReactNode;
+ children: React.ReactElement;
+ [prop: string]: any;
+}> = ({ title, children, ...rest }) => {
+ const { type } = children;
+
+ const style = {
+ display: 'inline-flex',
+ padding: '0 8px',
+ alignItems: 'center',
+ };
+
+ const spacing = ;
+
+ if (type === 'input' && children.props.type === 'checkbox') {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+const Demo = () => {
+ const [hover, hoverProps] = useControl('checked', true);
+ const [focus, focusProps] = useControl('checked', false);
+ const [click, clickProps] = useControl('checked', false);
+ const [contextMenu, contextMenuProps] = useControl('checked', false);
+
+ const [placement, placementProps] = useControl('value', 'right');
+ const [stretch, stretchProps] = useControl('value', '');
+ const [motion, motionProps] = useControl('checked', true);
+ const [destroyPopupOnHide, destroyPopupOnHideProps] = useControl(
+ 'checked',
+ false,
+ );
+ const [mask, maskProps] = useControl('checked', false);
+ const [maskClosable, maskClosableProps] = useControl('checked', true);
+ const [forceRender, forceRenderProps] = useControl('checked', false);
+ const [offsetX, offsetXProps] = useControl('value', 0);
+ const [offsetY, offsetYProps] = useControl('value', 0);
+
+ const actions = {
+ hover,
+ focus,
+ click,
+ contextMenu,
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default Demo;
diff --git a/docs/examples/click-nested.tsx b/docs/examples/click-nested.tsx
new file mode 100644
index 0000000..11c7e43
--- /dev/null
+++ b/docs/examples/click-nested.tsx
@@ -0,0 +1,94 @@
+/* eslint no-console:0 */
+
+import Trigger from 'rc-trigger';
+import React from 'react';
+import '../../assets/index.less';
+
+const builtinPlacements = {
+ left: {
+ points: ['cr', 'cl'],
+ },
+ right: {
+ points: ['cl', 'cr'],
+ },
+ top: {
+ points: ['bc', 'tc'],
+ },
+ bottom: {
+ points: ['tc', 'bc'],
+ },
+ topLeft: {
+ points: ['bl', 'tl'],
+ },
+ topRight: {
+ points: ['br', 'tr'],
+ },
+ bottomRight: {
+ points: ['tr', 'br'],
+ },
+ bottomLeft: {
+ points: ['tl', 'bl'],
+ },
+};
+
+const popupBorderStyle = {
+ border: '1px solid red',
+ padding: 10,
+ background: 'rgba(255, 0, 0, 0.1)',
+};
+
+const NestPopup = ({ open, setOpen }) => {
+ return (
+ i am a click popup}
+ popupVisible={open}
+ onPopupVisibleChange={setOpen}
+ >
+
+ i am a click popup{' '}
+
+
+
+ );
+};
+
+NestPopup.displayName = '🐞 NestPopup';
+
+const Test = () => {
+ const [open1, setOpen1] = React.useState(false);
+ const [open2, setOpen2] = React.useState(false);
+
+ return (
+
+
+
+ }
+ fresh
+ >
+ Click Me
+
+
+
+ );
+};
+
+export default Test;
diff --git a/docs/examples/clip.tsx b/docs/examples/clip.tsx
new file mode 100644
index 0000000..5f096b4
--- /dev/null
+++ b/docs/examples/clip.tsx
@@ -0,0 +1,108 @@
+/* eslint no-console:0 */
+import Trigger from 'rc-trigger';
+import React from 'react';
+import '../../assets/index.less';
+
+const builtinPlacements = {
+ top: {
+ points: ['bc', 'tc'],
+ overflow: {
+ adjustX: true,
+ adjustY: true,
+ },
+ offset: [0, 0],
+ },
+ bottom: {
+ points: ['tc', 'bc'],
+ overflow: {
+ adjustX: true,
+ adjustY: true,
+ },
+ offset: [0, 0],
+ },
+};
+
+const popupPlacement = 'top';
+
+export default () => {
+ const [scale, setScale] = React.useState('1');
+
+ return (
+
+
+
+ setScale(e.target.value)}
+ />
+
+
+
+
+ Popup
+
+ }
+ getPopupContainer={(n) => n.parentNode as any}
+ popupStyle={{ boxShadow: '0 0 5px red' }}
+ popupPlacement={popupPlacement}
+ builtinPlacements={builtinPlacements}
+ stretch="minWidth"
+ >
+
+ Target
+
+
+
+
+
+ {/* */}
+
+ );
+};
diff --git a/docs/examples/container.tsx b/docs/examples/container.tsx
new file mode 100644
index 0000000..3a00aa0
--- /dev/null
+++ b/docs/examples/container.tsx
@@ -0,0 +1,198 @@
+/* eslint no-console:0 */
+import Trigger from 'rc-trigger';
+import React from 'react';
+import '../../assets/index.less';
+
+const builtinPlacements = {
+ topLeft: {
+ points: ['bl', 'tl'],
+ overflow: {
+ shiftX: 50,
+ adjustY: true,
+ },
+ offset: [0, 0],
+ targetOffset: [10, 0],
+ },
+ bottomLeft: {
+ points: ['tl', 'bl'],
+ overflow: {
+ adjustX: true,
+ adjustY: true,
+ },
+ },
+ top: {
+ points: ['bc', 'tc'],
+ overflow: {
+ shiftX: 50,
+ adjustY: true,
+ },
+ offset: [0, -10],
+ },
+ bottom: {
+ points: ['tc', 'bc'],
+ overflow: {
+ shiftX: true,
+ adjustY: true,
+ },
+ offset: [0, 10],
+ htmlRegion: 'scroll' as const,
+ },
+ left: {
+ points: ['cr', 'cl'],
+ overflow: {
+ adjustX: true,
+ shiftY: true,
+ },
+ offset: [-10, 0],
+ },
+ right: {
+ points: ['cl', 'cr'],
+ overflow: {
+ adjustX: true,
+ shiftY: 24,
+ },
+ offset: [10, 0],
+ },
+};
+
+const popupPlacement = 'top';
+
+export default () => {
+ console.log('Demo Render!');
+
+ const [visible, setVisible] = React.useState(false);
+ const [scale, setScale] = React.useState('1');
+ const [targetVisible, setTargetVisible] = React.useState(true);
+
+ const rootRef = React.useRef();
+ const popHolderRef = React.useRef();
+ const scrollRef = React.useRef();
+
+ React.useEffect(() => {
+ scrollRef.current.scrollLeft = window.innerWidth;
+ scrollRef.current.scrollTop = window.innerHeight / 2;
+ }, []);
+
+ return (
+
+
+
+ setScale(e.target.value)}
+ />
+
+
+
+
+
+
+
+ Popup
+
+ }
+ popupTransitionName="rc-trigger-popup-zoom"
+ popupStyle={{ boxShadow: '0 0 5px red' }}
+ popupVisible={visible}
+ onPopupVisibleChange={(nextVisible) => {
+ setVisible(nextVisible);
+ }}
+ // getPopupContainer={() => popHolderRef.current}
+ popupPlacement={popupPlacement}
+ builtinPlacements={builtinPlacements}
+ stretch="minWidth"
+ onPopupAlign={(domNode, align) => {
+ console.log('onPopupAlign:', domNode, align);
+ }}
+ >
+
+ Target
+
+
+
+
+
+
+ {/* */}
+
+ );
+};
diff --git a/docs/examples/inside.tsx b/docs/examples/inside.tsx
new file mode 100644
index 0000000..42c19d4
--- /dev/null
+++ b/docs/examples/inside.tsx
@@ -0,0 +1,180 @@
+/* eslint no-console:0 */
+import React from 'react';
+import '../../assets/index.less';
+import Trigger, { type BuildInPlacements } from '../../src';
+
+const experimentalConfig = {
+ _experimental: {
+ dynamicInset: true,
+ },
+};
+
+export const builtinPlacements: BuildInPlacements = {
+ top: {
+ points: ['bc', 'tc'],
+ overflow: {
+ shiftX: 0,
+ adjustY: true,
+ },
+ offset: [0, 0],
+ ...experimentalConfig,
+ },
+ topLeft: {
+ points: ['bl', 'tl'],
+ overflow: {
+ adjustX: true,
+ adjustY: false,
+ shiftY: true,
+ },
+ offset: [0, -20],
+ ...experimentalConfig,
+ },
+ topRight: {
+ points: ['br', 'tr'],
+ overflow: {
+ adjustX: true,
+ adjustY: true,
+ },
+ offset: [0, 0],
+ ...experimentalConfig,
+ },
+ left: {
+ points: ['cr', 'cl'],
+ overflow: {
+ adjustX: true,
+ shiftY: true,
+ },
+ offset: [0, 0],
+ ...experimentalConfig,
+ },
+ leftTop: {
+ points: ['tr', 'tl'],
+ overflow: {
+ adjustX: true,
+ adjustY: true,
+ },
+ offset: [0, 0],
+ ...experimentalConfig,
+ },
+ leftBottom: {
+ points: ['br', 'bl'],
+ overflow: {
+ adjustX: true,
+ adjustY: true,
+ },
+ offset: [0, 0],
+ ...experimentalConfig,
+ },
+ right: {
+ points: ['cl', 'cr'],
+ overflow: {
+ adjustX: true,
+ shiftY: true,
+ },
+ offset: [0, 0],
+ ...experimentalConfig,
+ },
+ bottom: {
+ points: ['tc', 'bc'],
+ overflow: {
+ shiftX: 50,
+ adjustY: true,
+ },
+ offset: [0, 0],
+ ...experimentalConfig,
+ },
+ bottomLeft: {
+ points: ['tl', 'bl'],
+ overflow: {
+ shiftX: 50,
+ adjustY: true,
+ shiftY: true,
+ },
+ offset: [0, 20],
+ ...experimentalConfig,
+ },
+};
+
+const popupPlacement = 'bottomLeft';
+
+export default () => {
+ const [popupHeight, setPopupHeight] = React.useState(60);
+
+ const containerRef = React.useRef();
+
+ React.useEffect(() => {
+ containerRef.current.scrollLeft = document.defaultView.innerWidth;
+ containerRef.current.scrollTop = document.defaultView.innerHeight;
+ }, []);
+
+ return (
+ <>
+
+
+
+
+
+
+ Popup
+
+ }
+ popupVisible
+ getPopupContainer={() => containerRef.current}
+ popupPlacement={popupPlacement}
+ builtinPlacements={builtinPlacements}
+ >
+
+ Target
+
+
+
+
+ >
+ );
+};
diff --git a/docs/examples/large-popup.tsx b/docs/examples/large-popup.tsx
new file mode 100644
index 0000000..8d402d3
--- /dev/null
+++ b/docs/examples/large-popup.tsx
@@ -0,0 +1,103 @@
+/* eslint no-console:0 */
+import Trigger from 'rc-trigger';
+import React from 'react';
+import '../../assets/index.less';
+
+const builtinPlacements = {
+ top: {
+ points: ['bc', 'tc'],
+ overflow: {
+ shiftY: true,
+ adjustY: true,
+ },
+ offset: [0, -10],
+ },
+ bottom: {
+ points: ['tc', 'bc'],
+ overflow: {
+ shiftY: true,
+ adjustY: true,
+ },
+ offset: [0, 10],
+ htmlRegion: 'scroll' as const,
+ },
+};
+
+export default () => {
+ const containerRef = React.useRef();
+
+ React.useEffect(() => {
+ console.clear();
+ containerRef.current.scrollTop = document.defaultView.innerHeight * 0.75;
+ }, []);
+
+ return (
+
+
+
+
+
+ Popup 75vh
+
+ }
+ popupStyle={{ boxShadow: '0 0 5px red' }}
+ popupVisible
+ popupPlacement="top"
+ builtinPlacements={builtinPlacements}
+ >
+
+ Target
+
+
+
+
+
+
+ {/* */}
+
+ );
+};
diff --git a/docs/examples/nested.tsx b/docs/examples/nested.tsx
new file mode 100644
index 0000000..3412438
--- /dev/null
+++ b/docs/examples/nested.tsx
@@ -0,0 +1,132 @@
+/* eslint no-console:0 */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import Trigger from 'rc-trigger';
+import '../../assets/index.less';
+
+const builtinPlacements = {
+ left: {
+ points: ['cr', 'cl'],
+ },
+ right: {
+ points: ['cl', 'cr'],
+ },
+ top: {
+ points: ['bc', 'tc'],
+ },
+ bottom: {
+ points: ['tc', 'bc'],
+ },
+ topLeft: {
+ points: ['bl', 'tl'],
+ },
+ topRight: {
+ points: ['br', 'tr'],
+ },
+ bottomRight: {
+ points: ['tr', 'br'],
+ },
+ bottomLeft: {
+ points: ['tl', 'bl'],
+ },
+};
+
+const popupBorderStyle = {
+ border: '1px solid red',
+ padding: 10,
+};
+
+const OuterContent = ({ getContainer }) => {
+ return ReactDOM.createPortal(
+
+ I am outer content
+
+
,
+ getContainer(),
+ );
+};
+
+const Test = () => {
+ const containerRef = React.useRef();
+ const outerDivRef = React.useRef();
+
+ const innerTrigger = (
+
+
+
containerRef.current}
+ popup={I am inner Trigger Popup
}
+ >
+
+ clickToShowInnerTrigger
+
+
+
+ );
+ return (
+
+
+
+ i am a click popup
+ outerDivRef.current} />
+
+ }
+ >
+
+ i am a hover popup }
+ >
+
+ trigger
+
+
+
+
+
+
+
+
+ trigger
+
+
+
+
+
+
+ );
+};
+
+export default Test;
diff --git a/docs/examples/point.less b/docs/examples/point.less
new file mode 100644
index 0000000..4adc341
--- /dev/null
+++ b/docs/examples/point.less
@@ -0,0 +1,3 @@
+.point-popup {
+ pointer-events: none;
+}
\ No newline at end of file
diff --git a/docs/examples/point.tsx b/docs/examples/point.tsx
new file mode 100644
index 0000000..228fb18
--- /dev/null
+++ b/docs/examples/point.tsx
@@ -0,0 +1,83 @@
+/* eslint no-console:0 */
+
+import React from 'react';
+import Trigger from 'rc-trigger';
+import '../../assets/index.less';
+import './point.less';
+
+const builtinPlacements = {
+ topLeft: {
+ points: ['tl', 'tl'],
+ },
+};
+
+const innerTrigger = (
+ This is popup
+);
+
+class Test extends React.Component {
+ state = {
+ action: 'click',
+ mouseEnterDelay: 0,
+ };
+
+ onActionChange = ({ target: { value } }) => {
+ this.setState({ action: value });
+ };
+
+ onDelayChange = ({ target: { value } }) => {
+ this.setState({ mouseEnterDelay: Number(value) || 0 });
+ };
+
+ render() {
+ const { action, mouseEnterDelay } = this.state;
+
+ return (
+
+
{' '}
+ {action === 'hover' && (
+
+ )}
+
+
+
+ Interactive region
+
+
+
+
+ );
+ }
+}
+
+export default Test;
diff --git a/docs/examples/shadow.tsx b/docs/examples/shadow.tsx
new file mode 100644
index 0000000..6a1f5a2
--- /dev/null
+++ b/docs/examples/shadow.tsx
@@ -0,0 +1,75 @@
+/* eslint no-console:0 */
+import Trigger from 'rc-trigger';
+import React from 'react';
+import { createRoot } from 'react-dom/client';
+import '../../assets/index.less';
+
+const Demo = () => {
+ return (
+
+
+ Popup
+
+ }
+ popupStyle={{ boxShadow: '0 0 5px red', position: 'absolute' }}
+ getPopupContainer={(item) => item.parentElement!}
+ popupAlign={{
+ points: ['bc', 'tc'],
+ overflow: {
+ shiftX: 50,
+ adjustY: true,
+ },
+ offset: [0, -10],
+ }}
+ stretch="minWidth"
+ autoDestroy
+ >
+
+ Target
+
+
+
+ );
+};
+
+export default () => {
+ React.useEffect(() => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ host.style.background = 'rgba(255,0,0,0.1)';
+ const shadowRoot = host.attachShadow({
+ mode: 'open',
+ delegatesFocus: false,
+ });
+ const container = document.createElement('div');
+ shadowRoot.appendChild(container);
+
+ createRoot(container).render();
+ }, []);
+
+ return null;
+};
diff --git a/docs/examples/simple.tsx b/docs/examples/simple.tsx
new file mode 100644
index 0000000..31b5388
--- /dev/null
+++ b/docs/examples/simple.tsx
@@ -0,0 +1,409 @@
+/* eslint no-console:0 */
+
+import Trigger from 'rc-trigger';
+import React from 'react';
+import '../../assets/index.less';
+
+const builtinPlacements = {
+ left: {
+ points: ['cr', 'cl'],
+ offset: [-10, 0],
+ },
+ right: {
+ points: ['cl', 'cr'],
+ offset: [10, 0],
+ },
+ top: {
+ points: ['bc', 'tc'],
+ offset: [0, -10],
+ },
+ bottom: {
+ points: ['tc', 'bc'],
+ offset: [0, 10],
+ },
+ topLeft: {
+ points: ['bl', 'tl'],
+ offset: [0, -10],
+ },
+ topRight: {
+ points: ['br', 'tr'],
+ offset: [0, -10],
+ },
+ bottomRight: {
+ points: ['tr', 'br'],
+ offset: [0, 10],
+ },
+ bottomLeft: {
+ points: ['tl', 'bl'],
+ offset: [0, 10],
+ },
+};
+
+function getPopupContainer(trigger) {
+ return trigger.parentNode;
+}
+
+const InnerTarget = (props) => (
+
+
This is a example of trigger usage.
+
You can adjust the value above
+
which will also change the behaviour of popup.
+
+);
+
+const RefTarget = React.forwardRef((props, ref) => {
+ React.useImperativeHandle(ref, () => ({}));
+
+ return ;
+});
+
+interface TestState {
+ mask: boolean;
+ maskClosable: boolean;
+ placement: string;
+ trigger: {
+ click?: boolean;
+ focus?: boolean;
+ hover?: boolean;
+ contextMenu?: boolean;
+ };
+ offsetX: number;
+ offsetY: number;
+ stretch: string;
+ transitionName: string;
+ destroyed?: boolean;
+ destroyPopupOnHide?: boolean;
+ autoDestroy?: boolean;
+ mobile?: boolean;
+}
+
+class Test extends React.Component {
+ state: TestState = {
+ mask: true,
+ maskClosable: true,
+ placement: 'bottom',
+ trigger: {
+ click: true,
+ },
+ offsetX: undefined,
+ offsetY: undefined,
+ stretch: 'minWidth',
+ transitionName: 'rc-trigger-popup-zoom',
+ };
+
+ onPlacementChange = (e) => {
+ this.setState({
+ placement: e.target.value,
+ });
+ };
+
+ onStretch = (e) => {
+ this.setState({
+ stretch: e.target.value,
+ });
+ };
+
+ onTransitionChange = (e) => {
+ this.setState({
+ transitionName: e.target.checked ? e.target.value : '',
+ });
+ };
+
+ onTriggerChange = ({ target: { checked, value } }) => {
+ this.setState(({ trigger }) => {
+ const clone = { ...trigger };
+
+ if (checked) {
+ clone[value] = 1;
+ } else {
+ delete clone[value];
+ }
+
+ return {
+ trigger: clone,
+ };
+ });
+ };
+
+ onOffsetXChange = (e) => {
+ const targetValue = e.target.value;
+ this.setState({
+ offsetX: targetValue || undefined,
+ });
+ };
+
+ onOffsetYChange = (e) => {
+ const targetValue = e.target.value;
+ this.setState({
+ offsetY: targetValue || undefined,
+ });
+ };
+
+ onVisibleChange = (visible) => {
+ console.log('tooltip', visible);
+ };
+
+ onMask = (e) => {
+ this.setState({
+ mask: e.target.checked,
+ });
+ };
+
+ onMaskClosable = (e) => {
+ this.setState({
+ maskClosable: e.target.checked,
+ });
+ };
+
+ getPopupAlign = () => {
+ const { offsetX, offsetY } = this.state;
+ return {
+ offset: [offsetX, offsetY],
+ overflow: {
+ adjustX: 1,
+ adjustY: 1,
+ },
+ };
+ };
+
+ destroy = () => {
+ this.setState({
+ destroyed: true,
+ });
+ };
+
+ destroyPopupOnHide = (e) => {
+ this.setState({
+ destroyPopupOnHide: e.target.checked,
+ });
+ };
+
+ autoDestroy = (e) => {
+ this.setState({
+ autoDestroy: e.target.checked,
+ });
+ };
+
+ render() {
+ const { state } = this;
+ const { trigger } = state;
+ if (state.destroyed) {
+ return null;
+ }
+ return (
+
+
+
+
+
+
+
+ trigger:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ i am a popup
}
+ popupTransitionName={state.transitionName}
+ mobile={
+ state.mobile
+ ? {
+ popupMotion: {
+ motionName: 'rc-trigger-popup-mobile-fade',
+ },
+ popupClassName: 'rc-trigger-popup-mobile',
+ popupStyle: {
+ padding: 16,
+ borderTop: '1px solid red',
+ background: '#FFF',
+ textAlign: 'center',
+ },
+ popupRender: (node) => (
+ <>
+
+
+
+ {node}
+ >
+ ),
+ }
+ : null
+ }
+ >
+
+
+
+
+ );
+ }
+}
+
+export default Test;
diff --git a/docs/examples/static-scroll.tsx b/docs/examples/static-scroll.tsx
new file mode 100644
index 0000000..1f0e6e2
--- /dev/null
+++ b/docs/examples/static-scroll.tsx
@@ -0,0 +1,64 @@
+/* eslint no-console:0 */
+import Trigger from 'rc-trigger';
+import React from 'react';
+import '../../assets/index.less';
+import { builtinPlacements } from './inside';
+
+export default () => {
+ return (
+
+
+
+ Popup
+
+ }
+ popupStyle={{ boxShadow: '0 0 5px red' }}
+ popupVisible
+ builtinPlacements={builtinPlacements}
+ popupPlacement="top"
+ stretch="minWidth"
+ getPopupContainer={(e) => e.parentElement!}
+ >
+
+ Target
+
+
+ {new Array(20).fill(null).map((_, index) => (
+
+ Placeholder Line {index}
+
+ ))}
+
+
+ );
+};
diff --git a/docs/examples/visible-fallback.tsx b/docs/examples/visible-fallback.tsx
new file mode 100644
index 0000000..3e0881f
--- /dev/null
+++ b/docs/examples/visible-fallback.tsx
@@ -0,0 +1,109 @@
+/* eslint no-console:0 */
+import type { AlignType, TriggerRef } from 'rc-trigger';
+import Trigger from 'rc-trigger';
+import React from 'react';
+import '../../assets/index.less';
+
+const builtinPlacements: Record = {
+ top: {
+ points: ['bc', 'tc'],
+ overflow: {
+ adjustX: true,
+ adjustY: true,
+ },
+ offset: [0, 0],
+ htmlRegion: 'visibleFirst',
+ },
+ bottom: {
+ points: ['tc', 'bc'],
+ overflow: {
+ adjustX: true,
+ adjustY: true,
+ },
+ offset: [0, 0],
+ htmlRegion: 'visibleFirst',
+ },
+};
+
+export default () => {
+ const [enoughTop, setEnoughTop] = React.useState(true);
+
+ const triggerRef = React.useRef();
+
+ React.useEffect(() => {
+ triggerRef.current?.forceAlign();
+ }, [enoughTop]);
+
+ return (
+
+ `visibleFirst` should not show in hidden region if still scrollable
+
+
+
+
+
+ Should Always place bottom
+
+ }
+ getPopupContainer={(n) => n.parentNode as any}
+ popupStyle={{ boxShadow: '0 0 5px red' }}
+ popupPlacement={enoughTop ? 'bottom' : 'top'}
+ builtinPlacements={builtinPlacements}
+ stretch="minWidth"
+ >
+
+ Target
+
+
+
+
+ );
+};
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..2782d1c
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,7 @@
+---
+hero:
+ title: rc-trigger
+ description: React Trigger Component
+---
+
+
\ No newline at end of file
diff --git a/index.js b/index.js
new file mode 100644
index 0000000..274f820
--- /dev/null
+++ b/index.js
@@ -0,0 +1,3 @@
+// export this package's api
+import Trigger from './src/';
+export default Trigger;
diff --git a/jest.config.js b/jest.config.js
new file mode 100644
index 0000000..5328c18
--- /dev/null
+++ b/jest.config.js
@@ -0,0 +1,3 @@
+module.exports = {
+ setupFiles: ['./tests/setup.js'],
+};
diff --git a/now.json b/now.json
new file mode 100644
index 0000000..76d28fa
--- /dev/null
+++ b/now.json
@@ -0,0 +1,11 @@
+{
+ "version": 2,
+ "name": "rc-trigger",
+ "builds": [
+ {
+ "src": "package.json",
+ "use": "@now/static-build",
+ "config": { "distDir": ".doc" }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..7d14086
--- /dev/null
+++ b/package.json
@@ -0,0 +1,76 @@
+{
+ "name": "@rc-component/trigger",
+ "version": "2.2.0",
+ "description": "base abstract trigger component for react",
+ "engines": {
+ "node": ">=8.x"
+ },
+ "keywords": [
+ "react",
+ "react-component",
+ "react-trigger",
+ "trigger"
+ ],
+ "homepage": "https://github.com/react-component/trigger",
+ "author": "",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/react-component/trigger.git"
+ },
+ "bugs": {
+ "url": "https://github.com/react-component/trigger/issues"
+ },
+ "files": [
+ "es",
+ "lib",
+ "assets/**/*.css",
+ "assets/**/*.less"
+ ],
+ "license": "MIT",
+ "main": "./lib/index",
+ "module": "./es/index",
+ "scripts": {
+ "start": "dumi dev",
+ "build": "dumi build",
+ "compile": "father build && lessc assets/index.less assets/index.css",
+ "prepublishOnly": "npm run compile && np --yolo --no-publish",
+ "lint": "eslint src/ docs/examples/ --ext .tsx,.ts,.jsx,.js",
+ "test": "rc-test",
+ "coverage": "rc-test --coverage",
+ "now-build": "npm run build"
+ },
+ "devDependencies": {
+ "@rc-component/father-plugin": "^1.0.0",
+ "@testing-library/jest-dom": "^6.1.4",
+ "@testing-library/react": "^15.0.4",
+ "@types/classnames": "^2.2.10",
+ "@types/jest": "^29.5.2",
+ "@types/node": "^20.11.6",
+ "@types/react": "^18.0.0",
+ "@types/react-dom": "^18.0.11",
+ "@umijs/fabric": "^4.0.1",
+ "cross-env": "^7.0.1",
+ "dumi": "^2.1.0",
+ "eslint": "^8.51.0",
+ "father": "^4.0.0",
+ "less": "^4.2.0",
+ "np": "^10.0.5",
+ "rc-test": "^7.0.13",
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0",
+ "regenerator-runtime": "^0.14.0",
+ "typescript": "^5.1.6"
+ },
+ "dependencies": {
+ "@babel/runtime": "^7.23.2",
+ "@rc-component/portal": "^1.1.0",
+ "classnames": "^2.3.2",
+ "rc-motion": "^2.0.0",
+ "rc-resize-observer": "^1.3.1",
+ "rc-util": "^5.38.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+}
diff --git a/src/Popup/Arrow.tsx b/src/Popup/Arrow.tsx
new file mode 100644
index 0000000..c61c49e
--- /dev/null
+++ b/src/Popup/Arrow.tsx
@@ -0,0 +1,66 @@
+import classNames from 'classnames';
+import * as React from 'react';
+import type { AlignType, ArrowPos, ArrowTypeOuter } from '../interface';
+
+export interface ArrowProps {
+ prefixCls: string;
+ align: AlignType;
+ arrow: ArrowTypeOuter;
+ arrowPos: ArrowPos;
+}
+
+export default function Arrow(props: ArrowProps) {
+ const { prefixCls, align, arrow, arrowPos } = props;
+
+ const { className, content } = arrow || {};
+ const { x = 0, y = 0 } = arrowPos;
+
+ const arrowRef = React.useRef();
+
+ // Skip if no align
+ if (!align || !align.points) {
+ return null;
+ }
+
+ const alignStyle: React.CSSProperties = {
+ position: 'absolute',
+ };
+
+ // Skip if no need to align
+ if (align.autoArrow !== false) {
+ const popupPoints = align.points[0];
+ const targetPoints = align.points[1];
+ const popupTB = popupPoints[0];
+ const popupLR = popupPoints[1];
+ const targetTB = targetPoints[0];
+ const targetLR = targetPoints[1];
+
+ // Top & Bottom
+ if (popupTB === targetTB || !['t', 'b'].includes(popupTB)) {
+ alignStyle.top = y;
+ } else if (popupTB === 't') {
+ alignStyle.top = 0;
+ } else {
+ alignStyle.bottom = 0;
+ }
+
+ // Left & Right
+ if (popupLR === targetLR || !['l', 'r'].includes(popupLR)) {
+ alignStyle.left = x;
+ } else if (popupLR === 'l') {
+ alignStyle.left = 0;
+ } else {
+ alignStyle.right = 0;
+ }
+ }
+
+ return (
+
+ {content}
+
+ );
+}
diff --git a/src/Popup/Mask.tsx b/src/Popup/Mask.tsx
new file mode 100644
index 0000000..86429ee
--- /dev/null
+++ b/src/Popup/Mask.tsx
@@ -0,0 +1,40 @@
+import classNames from 'classnames';
+import type { CSSMotionProps } from 'rc-motion';
+import CSSMotion from 'rc-motion';
+import * as React from 'react';
+
+export interface MaskProps {
+ prefixCls: string;
+ open?: boolean;
+ zIndex?: number;
+ mask?: boolean;
+
+ // Motion
+ motion?: CSSMotionProps;
+}
+
+export default function Mask(props: MaskProps) {
+ const {
+ prefixCls,
+ open,
+ zIndex,
+
+ mask,
+ motion,
+ } = props;
+
+ if (!mask) {
+ return null;
+ }
+
+ return (
+
+ {({ className }) => (
+
+ )}
+
+ );
+}
diff --git a/src/Popup/PopupContent.tsx b/src/Popup/PopupContent.tsx
new file mode 100644
index 0000000..eb96dff
--- /dev/null
+++ b/src/Popup/PopupContent.tsx
@@ -0,0 +1,17 @@
+import * as React from 'react';
+
+export interface PopupContentProps {
+ children?: React.ReactNode;
+ cache?: boolean;
+}
+
+const PopupContent = React.memo(
+ ({ children }: PopupContentProps) => children as React.ReactElement,
+ (_, next) => next.cache,
+);
+
+if (process.env.NODE_ENV !== 'production') {
+ PopupContent.displayName = 'PopupContent';
+}
+
+export default PopupContent;
diff --git a/src/Popup/index.tsx b/src/Popup/index.tsx
new file mode 100644
index 0000000..e5d64d4
--- /dev/null
+++ b/src/Popup/index.tsx
@@ -0,0 +1,285 @@
+import classNames from 'classnames';
+import type { CSSMotionProps } from 'rc-motion';
+import CSSMotion from 'rc-motion';
+import ResizeObserver from 'rc-resize-observer';
+import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect';
+import { composeRef } from 'rc-util/lib/ref';
+import * as React from 'react';
+import type { TriggerProps } from '../';
+import type { AlignType, ArrowPos, ArrowTypeOuter } from '../interface';
+import Arrow from './Arrow';
+import Mask from './Mask';
+import PopupContent from './PopupContent';
+
+export interface PopupProps {
+ prefixCls: string;
+ className?: string;
+ style?: React.CSSProperties;
+ popup?: TriggerProps['popup'];
+ target: HTMLElement;
+ onMouseEnter?: React.MouseEventHandler;
+ onMouseLeave?: React.MouseEventHandler;
+ onPointerEnter?: React.MouseEventHandler;
+ zIndex?: number;
+
+ mask?: boolean;
+ onVisibleChanged: (visible: boolean) => void;
+
+ // Arrow
+ align?: AlignType;
+ arrow?: ArrowTypeOuter;
+ arrowPos: ArrowPos;
+
+ // Open
+ open: boolean;
+ /** Tell Portal that should keep in screen. e.g. should wait all motion end */
+ keepDom: boolean;
+ fresh?: boolean;
+
+ // Click
+ onClick?: React.MouseEventHandler;
+
+ // Motion
+ motion?: CSSMotionProps;
+ maskMotion?: CSSMotionProps;
+
+ // Portal
+ forceRender?: boolean;
+ getPopupContainer?: TriggerProps['getPopupContainer'];
+ autoDestroy?: boolean;
+ portal: React.ComponentType;
+
+ // Align
+ ready: boolean;
+ offsetX: number;
+ offsetY: number;
+ offsetR: number;
+ offsetB: number;
+ onAlign: VoidFunction;
+ onPrepare: () => Promise;
+
+ // stretch
+ stretch?: string;
+ targetWidth?: number;
+ targetHeight?: number;
+}
+
+const Popup = React.forwardRef((props, ref) => {
+ const {
+ popup,
+ className,
+ prefixCls,
+ style,
+ target,
+
+ onVisibleChanged,
+
+ // Open
+ open,
+ keepDom,
+ fresh,
+
+ // Click
+ onClick,
+
+ // Mask
+ mask,
+
+ // Arrow
+ arrow,
+ arrowPos,
+ align,
+
+ // Motion
+ motion,
+ maskMotion,
+
+ // Portal
+ forceRender,
+ getPopupContainer,
+ autoDestroy,
+ portal: Portal,
+
+ zIndex,
+
+ onMouseEnter,
+ onMouseLeave,
+ onPointerEnter,
+
+ ready,
+ offsetX,
+ offsetY,
+ offsetR,
+ offsetB,
+ onAlign,
+ onPrepare,
+
+ stretch,
+ targetWidth,
+ targetHeight,
+ } = props;
+
+ const childNode = typeof popup === 'function' ? popup() : popup;
+
+ // We can not remove holder only when motion finished.
+ const isNodeVisible = open || keepDom;
+
+ // ======================= Container ========================
+ const getPopupContainerNeedParams = getPopupContainer?.length > 0;
+
+ const [show, setShow] = React.useState(
+ !getPopupContainer || !getPopupContainerNeedParams,
+ );
+
+ // Delay to show since `getPopupContainer` need target element
+ useLayoutEffect(() => {
+ if (!show && getPopupContainerNeedParams && target) {
+ setShow(true);
+ }
+ }, [show, getPopupContainerNeedParams, target]);
+
+ // ========================= Render =========================
+ if (!show) {
+ return null;
+ }
+
+ // >>>>> Offset
+ const AUTO = 'auto' as const;
+
+ const offsetStyle: React.CSSProperties = {
+ left: '-1000vw',
+ top: '-1000vh',
+ right: AUTO,
+ bottom: AUTO,
+ };
+
+ // Set align style
+ if (ready || !open) {
+ 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';
+
+ if (alignRight) {
+ offsetStyle.right = offsetR;
+ offsetStyle.left = AUTO;
+ } else {
+ offsetStyle.left = offsetX;
+ offsetStyle.right = AUTO;
+ }
+
+ if (alignBottom) {
+ offsetStyle.bottom = offsetB;
+ offsetStyle.top = AUTO;
+ } else {
+ offsetStyle.top = offsetY;
+ offsetStyle.bottom = AUTO;
+ }
+ }
+
+ // >>>>> Misc
+ const miscStyle: React.CSSProperties = {};
+ if (stretch) {
+ if (stretch.includes('height') && targetHeight) {
+ miscStyle.height = targetHeight;
+ } else if (stretch.includes('minHeight') && targetHeight) {
+ miscStyle.minHeight = targetHeight;
+ }
+ if (stretch.includes('width') && targetWidth) {
+ miscStyle.width = targetWidth;
+ } else if (stretch.includes('minWidth') && targetWidth) {
+ miscStyle.minWidth = targetWidth;
+ }
+ }
+
+ if (!open) {
+ miscStyle.pointerEvents = 'none';
+ }
+
+ return (
+ getPopupContainer(target))}
+ autoDestroy={autoDestroy}
+ >
+
+
+ {(resizeObserverRef) => {
+ return (
+ {
+ motion?.onVisibleChanged?.(nextVisible);
+ onVisibleChanged(nextVisible);
+ }}
+ >
+ {(
+ { className: motionClassName, style: motionStyle },
+ motionRef,
+ ) => {
+ const cls = classNames(prefixCls, motionClassName, className);
+
+ return (
+
+ {arrow && (
+
+ )}
+
+ {childNode}
+
+
+ );
+ }}
+
+ );
+ }}
+
+
+ );
+});
+
+if (process.env.NODE_ENV !== 'production') {
+ Popup.displayName = 'Popup';
+}
+
+export default Popup;
diff --git a/src/TriggerWrapper.tsx b/src/TriggerWrapper.tsx
new file mode 100644
index 0000000..48d27cf
--- /dev/null
+++ b/src/TriggerWrapper.tsx
@@ -0,0 +1,38 @@
+import { fillRef, supportRef, useComposeRef } from 'rc-util/lib/ref';
+import * as React from 'react';
+import type { TriggerProps } from '.';
+
+export interface TriggerWrapperProps {
+ getTriggerDOMNode?: TriggerProps['getTriggerDOMNode'];
+ children: React.ReactElement;
+}
+
+const TriggerWrapper = React.forwardRef(
+ (props, ref) => {
+ const { children, getTriggerDOMNode } = props;
+
+ const canUseRef = supportRef(children);
+
+ // When use `getTriggerDOMNode`, we should do additional work to get the real dom
+ const setRef = React.useCallback(
+ (node) => {
+ fillRef(ref, getTriggerDOMNode ? getTriggerDOMNode(node) : node);
+ },
+ [getTriggerDOMNode],
+ );
+
+ const mergedRef = useComposeRef(setRef, (children as any).ref);
+
+ return canUseRef
+ ? React.cloneElement(children, {
+ ref: mergedRef,
+ })
+ : children;
+ },
+);
+
+if (process.env.NODE_ENV !== 'production') {
+ TriggerWrapper.displayName = 'TriggerWrapper';
+}
+
+export default TriggerWrapper;
diff --git a/src/context.ts b/src/context.ts
new file mode 100644
index 0000000..429b350
--- /dev/null
+++ b/src/context.ts
@@ -0,0 +1,9 @@
+import * as React from 'react';
+
+export interface TriggerContextProps {
+ registerSubPopup: (id: string, node: HTMLElement) => void;
+}
+
+const TriggerContext = React.createContext(null);
+
+export default TriggerContext;
diff --git a/src/hooks/useAction.ts b/src/hooks/useAction.ts
new file mode 100644
index 0000000..99d78bb
--- /dev/null
+++ b/src/hooks/useAction.ts
@@ -0,0 +1,37 @@
+import * as React from 'react';
+import type { ActionType } from '../interface';
+
+type ActionTypes = ActionType | ActionType[];
+
+function toArray(val?: T | T[]) {
+ return val ? (Array.isArray(val) ? val : [val]) : [];
+}
+
+export default function useAction(
+ mobile: boolean,
+ action: ActionTypes,
+ showAction?: ActionTypes,
+ hideAction?: ActionTypes,
+): [showAction: Set, hideAction: Set] {
+ return React.useMemo(() => {
+ const mergedShowAction = toArray(showAction ?? action);
+ const mergedHideAction = toArray(hideAction ?? action);
+
+ const showActionSet = new Set(mergedShowAction);
+ const hideActionSet = new Set(mergedHideAction);
+
+ if (mobile) {
+ if (showActionSet.has('hover')) {
+ showActionSet.delete('hover');
+ showActionSet.add('click');
+ }
+
+ if (hideActionSet.has('hover')) {
+ hideActionSet.delete('hover');
+ hideActionSet.add('click');
+ }
+ }
+
+ return [showActionSet, hideActionSet];
+ }, [mobile, action, showAction, hideAction]);
+}
diff --git a/src/hooks/useAlign.ts b/src/hooks/useAlign.ts
new file mode 100644
index 0000000..6c15a09
--- /dev/null
+++ b/src/hooks/useAlign.ts
@@ -0,0 +1,746 @@
+import { isDOM } from 'rc-util/lib/Dom/findDOMNode';
+import isVisible from 'rc-util/lib/Dom/isVisible';
+import useEvent from 'rc-util/lib/hooks/useEvent';
+import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect';
+import * as React from 'react';
+import type { TriggerProps } from '..';
+import type {
+ AlignPointLeftRight,
+ AlignPointTopBottom,
+ AlignType,
+ OffsetType,
+} from '../interface';
+import { collectScroller, getVisibleArea, getWin, toNum } from '../util';
+
+type Rect = Record<'x' | 'y' | 'width' | 'height', number>;
+
+type Points = [topBottom: AlignPointTopBottom, leftRight: AlignPointLeftRight];
+
+function getUnitOffset(size: number, offset: OffsetType = 0) {
+ const offsetStr = `${offset}`;
+ const cells = offsetStr.match(/^(.*)\%$/);
+ if (cells) {
+ return size * (parseFloat(cells[1]) / 100);
+ }
+ return parseFloat(offsetStr);
+}
+
+function getNumberOffset(
+ rect: { width: number; height: number },
+ offset?: OffsetType[],
+) {
+ const [offsetX, offsetY] = offset || [];
+
+ return [
+ getUnitOffset(rect.width, offsetX),
+ getUnitOffset(rect.height, offsetY),
+ ];
+}
+
+function splitPoints(points: string = ''): Points {
+ return [points[0] as any, points[1] as any];
+}
+
+function getAlignPoint(rect: Rect, points: Points) {
+ const topBottom = points[0];
+ const leftRight = points[1];
+
+ let x: number;
+ let y: number;
+
+ // Top & Bottom
+ if (topBottom === 't') {
+ y = rect.y;
+ } else if (topBottom === 'b') {
+ y = rect.y + rect.height;
+ } else {
+ y = rect.y + rect.height / 2;
+ }
+
+ // Left & Right
+ if (leftRight === 'l') {
+ x = rect.x;
+ } else if (leftRight === 'r') {
+ x = rect.x + rect.width;
+ } else {
+ x = rect.x + rect.width / 2;
+ }
+
+ return { x, y };
+}
+
+function reversePoints(points: Points, index: number): string {
+ const reverseMap = {
+ t: 'b',
+ b: 't',
+ l: 'r',
+ r: 'l',
+ };
+
+ return points
+ .map((point, i) => {
+ if (i === index) {
+ return reverseMap[point] || 'c';
+ }
+ return point;
+ })
+ .join('');
+}
+
+export default function useAlign(
+ open: boolean,
+ popupEle: HTMLElement,
+ target: HTMLElement | [x: number, y: number],
+ placement: string,
+ builtinPlacements: any,
+ popupAlign?: AlignType,
+ onPopupAlign?: TriggerProps['onPopupAlign'],
+): [
+ ready: boolean,
+ offsetX: number,
+ offsetY: number,
+ offsetR: number,
+ offsetB: number,
+ arrowX: number,
+ arrowY: number,
+ scaleX: number,
+ scaleY: number,
+ align: AlignType,
+ onAlign: VoidFunction,
+] {
+ const [offsetInfo, setOffsetInfo] = React.useState<{
+ ready: boolean;
+ offsetX: number;
+ offsetY: number;
+ offsetR: number;
+ offsetB: number;
+ arrowX: number;
+ arrowY: number;
+ scaleX: number;
+ scaleY: number;
+ align: AlignType;
+ }>({
+ ready: false,
+ offsetX: 0,
+ offsetY: 0,
+ offsetR: 0,
+ offsetB: 0,
+ arrowX: 0,
+ arrowY: 0,
+ scaleX: 1,
+ scaleY: 1,
+ align: builtinPlacements[placement] || {},
+ });
+ const alignCountRef = React.useRef(0);
+
+ const scrollerList = React.useMemo(() => {
+ if (!popupEle) {
+ return [];
+ }
+
+ return collectScroller(popupEle);
+ }, [popupEle]);
+
+ // ========================= Flip ==========================
+ // We will memo flip info.
+ // If size change to make flip, it will memo the flip info and use it in next align.
+ const prevFlipRef = React.useRef<{
+ tb?: boolean;
+ bt?: boolean;
+ lr?: boolean;
+ rl?: boolean;
+ }>({});
+
+ const resetFlipCache = () => {
+ prevFlipRef.current = {};
+ };
+
+ if (!open) {
+ resetFlipCache();
+ }
+
+ // ========================= Align =========================
+ const onAlign = useEvent(() => {
+ if (popupEle && target && open) {
+ const popupElement = popupEle;
+
+ const doc = popupElement.ownerDocument;
+ const win = getWin(popupElement);
+
+ const {
+ width,
+ height,
+ position: popupPosition,
+ } = win.getComputedStyle(popupElement);
+
+ const originLeft = popupElement.style.left;
+ const originTop = popupElement.style.top;
+ const originRight = popupElement.style.right;
+ const originBottom = popupElement.style.bottom;
+ const originOverflow = popupElement.style.overflow;
+
+ // Placement
+ const placementInfo: AlignType = {
+ ...builtinPlacements[placement],
+ ...popupAlign,
+ };
+
+ // placeholder element
+ const placeholderElement = doc.createElement('div');
+ popupElement.parentElement?.appendChild(placeholderElement);
+ placeholderElement.style.left = `${popupElement.offsetLeft}px`;
+ placeholderElement.style.top = `${popupElement.offsetTop}px`;
+ placeholderElement.style.position = popupPosition;
+ placeholderElement.style.height = `${popupElement.offsetHeight}px`;
+ placeholderElement.style.width = `${popupElement.offsetWidth}px`;
+
+ // Reset first
+ popupElement.style.left = '0';
+ popupElement.style.top = '0';
+ popupElement.style.right = 'auto';
+ popupElement.style.bottom = 'auto';
+ popupElement.style.overflow = 'hidden';
+
+ // Calculate align style, we should consider `transform` case
+ let targetRect: Rect;
+ if (Array.isArray(target)) {
+ targetRect = {
+ x: target[0],
+ y: target[1],
+ width: 0,
+ height: 0,
+ };
+ } else {
+ const rect = target.getBoundingClientRect();
+ targetRect = {
+ x: rect.x,
+ y: rect.y,
+ width: rect.width,
+ height: rect.height,
+ };
+ }
+ const popupRect = popupElement.getBoundingClientRect();
+ const {
+ clientWidth,
+ clientHeight,
+ scrollWidth,
+ scrollHeight,
+ scrollTop,
+ scrollLeft,
+ } = doc.documentElement;
+
+ const popupHeight = popupRect.height;
+ const popupWidth = popupRect.width;
+
+ const targetHeight = targetRect.height;
+ const targetWidth = targetRect.width;
+
+ // Get bounding of visible area
+ const visibleRegion = {
+ left: 0,
+ top: 0,
+ right: clientWidth,
+ bottom: clientHeight,
+ };
+
+ const scrollRegion = {
+ left: -scrollLeft,
+ top: -scrollTop,
+ right: scrollWidth - scrollLeft,
+ bottom: scrollHeight - scrollTop,
+ };
+
+ let { htmlRegion } = placementInfo;
+ const VISIBLE = 'visible' as const;
+ const VISIBLE_FIRST = 'visibleFirst' as const;
+ if (htmlRegion !== 'scroll' && htmlRegion !== VISIBLE_FIRST) {
+ htmlRegion = VISIBLE;
+ }
+ const isVisibleFirst = htmlRegion === VISIBLE_FIRST;
+
+ const scrollRegionArea = getVisibleArea(scrollRegion, scrollerList);
+ const visibleRegionArea = getVisibleArea(visibleRegion, scrollerList);
+
+ const visibleArea =
+ htmlRegion === VISIBLE ? visibleRegionArea : scrollRegionArea;
+
+ // When set to `visibleFirst`,
+ // the check `adjust` logic will use `visibleRegion` for check first.
+ const adjustCheckVisibleArea = isVisibleFirst
+ ? visibleRegionArea
+ : visibleArea;
+
+ // Record right & bottom align data
+ popupElement.style.left = 'auto';
+ popupElement.style.top = 'auto';
+ popupElement.style.right = '0';
+ popupElement.style.bottom = '0';
+
+ const popupMirrorRect = popupElement.getBoundingClientRect();
+
+ // Reset back
+ popupElement.style.left = originLeft;
+ popupElement.style.top = originTop;
+ popupElement.style.right = originRight;
+ popupElement.style.bottom = originBottom;
+ popupElement.style.overflow = originOverflow;
+
+ popupElement.parentElement?.removeChild(placeholderElement);
+
+ // Calculate scale
+ const scaleX = toNum(
+ Math.round((popupWidth / parseFloat(width)) * 1000) / 1000,
+ );
+ const scaleY = toNum(
+ Math.round((popupHeight / parseFloat(height)) * 1000) / 1000,
+ );
+
+ // No need to align since it's not visible in view
+ if (
+ scaleX === 0 ||
+ scaleY === 0 ||
+ (isDOM(target) && !isVisible(target))
+ ) {
+ return;
+ }
+
+ // Offset
+ const { offset, targetOffset } = placementInfo;
+ let [popupOffsetX, popupOffsetY] = getNumberOffset(popupRect, offset);
+ const [targetOffsetX, targetOffsetY] = getNumberOffset(
+ targetRect,
+ targetOffset,
+ );
+
+ targetRect.x -= targetOffsetX;
+ targetRect.y -= targetOffsetY;
+
+ // Points
+ const [popupPoint, targetPoint] = placementInfo.points || [];
+ const targetPoints = splitPoints(targetPoint);
+ const popupPoints = splitPoints(popupPoint);
+
+ const targetAlignPoint = getAlignPoint(targetRect, targetPoints);
+ const popupAlignPoint = getAlignPoint(popupRect, popupPoints);
+
+ // Real align info may not same as origin one
+ const nextAlignInfo = {
+ ...placementInfo,
+ };
+
+ // Next Offset
+ let nextOffsetX = targetAlignPoint.x - popupAlignPoint.x + popupOffsetX;
+ let nextOffsetY = targetAlignPoint.y - popupAlignPoint.y + popupOffsetY;
+
+ // ============== Intersection ===============
+ // Get area by position. Used for check if flip area is better
+ function getIntersectionVisibleArea(
+ offsetX: number,
+ offsetY: number,
+ area = visibleArea,
+ ) {
+ const l = popupRect.x + offsetX;
+ const t = popupRect.y + offsetY;
+
+ const r = l + popupWidth;
+ const b = t + popupHeight;
+
+ const visibleL = Math.max(l, area.left);
+ const visibleT = Math.max(t, area.top);
+ const visibleR = Math.min(r, area.right);
+ const visibleB = Math.min(b, area.bottom);
+
+ return Math.max(0, (visibleR - visibleL) * (visibleB - visibleT));
+ }
+
+ const originIntersectionVisibleArea = getIntersectionVisibleArea(
+ nextOffsetX,
+ nextOffsetY,
+ );
+
+ // As `visibleFirst`, we prepare this for check
+ const originIntersectionRecommendArea = getIntersectionVisibleArea(
+ nextOffsetX,
+ nextOffsetY,
+ visibleRegionArea,
+ );
+
+ // ========================== Overflow ===========================
+ const targetAlignPointTL = getAlignPoint(targetRect, ['t', 'l']);
+ const popupAlignPointTL = getAlignPoint(popupRect, ['t', 'l']);
+ const targetAlignPointBR = getAlignPoint(targetRect, ['b', 'r']);
+ const popupAlignPointBR = getAlignPoint(popupRect, ['b', 'r']);
+
+ const overflow = placementInfo.overflow || {};
+ const { adjustX, adjustY, shiftX, shiftY } = overflow;
+
+ const supportAdjust = (val: boolean | number) => {
+ if (typeof val === 'boolean') {
+ return val;
+ }
+ return val >= 0;
+ };
+
+ // Prepare position
+ let nextPopupY: number;
+ let nextPopupBottom: number;
+ let nextPopupX: number;
+ let nextPopupRight: number;
+
+ function syncNextPopupPosition() {
+ nextPopupY = popupRect.y + nextOffsetY;
+ nextPopupBottom = nextPopupY + popupHeight;
+ nextPopupX = popupRect.x + nextOffsetX;
+ nextPopupRight = nextPopupX + popupWidth;
+ }
+ syncNextPopupPosition();
+
+ // >>>>>>>>>> Top & Bottom
+ const needAdjustY = supportAdjust(adjustY);
+
+ const sameTB = popupPoints[0] === targetPoints[0];
+
+ // Bottom to Top
+ if (
+ needAdjustY &&
+ popupPoints[0] === 't' &&
+ (nextPopupBottom > adjustCheckVisibleArea.bottom ||
+ prevFlipRef.current.bt)
+ ) {
+ let tmpNextOffsetY: number = nextOffsetY;
+
+ if (sameTB) {
+ tmpNextOffsetY -= popupHeight - targetHeight;
+ } else {
+ tmpNextOffsetY =
+ targetAlignPointTL.y - popupAlignPointBR.y - popupOffsetY;
+ }
+
+ const newVisibleArea = getIntersectionVisibleArea(
+ nextOffsetX,
+ tmpNextOffsetY,
+ );
+ const newVisibleRecommendArea = getIntersectionVisibleArea(
+ nextOffsetX,
+ tmpNextOffsetY,
+ visibleRegionArea,
+ );
+
+ if (
+ // Of course use larger one
+ newVisibleArea > originIntersectionVisibleArea ||
+ (newVisibleArea === originIntersectionVisibleArea &&
+ (!isVisibleFirst ||
+ // Choose recommend one
+ newVisibleRecommendArea >= originIntersectionRecommendArea))
+ ) {
+ prevFlipRef.current.bt = true;
+ nextOffsetY = tmpNextOffsetY;
+ popupOffsetY = -popupOffsetY;
+
+ nextAlignInfo.points = [
+ reversePoints(popupPoints, 0),
+ reversePoints(targetPoints, 0),
+ ];
+ } else {
+ prevFlipRef.current.bt = false;
+ }
+ }
+
+ // Top to Bottom
+ if (
+ needAdjustY &&
+ popupPoints[0] === 'b' &&
+ (nextPopupY < adjustCheckVisibleArea.top || prevFlipRef.current.tb)
+ ) {
+ let tmpNextOffsetY: number = nextOffsetY;
+
+ if (sameTB) {
+ tmpNextOffsetY += popupHeight - targetHeight;
+ } else {
+ tmpNextOffsetY =
+ targetAlignPointBR.y - popupAlignPointTL.y - popupOffsetY;
+ }
+
+ const newVisibleArea = getIntersectionVisibleArea(
+ nextOffsetX,
+ tmpNextOffsetY,
+ );
+ const newVisibleRecommendArea = getIntersectionVisibleArea(
+ nextOffsetX,
+ tmpNextOffsetY,
+ visibleRegionArea,
+ );
+
+ if (
+ // Of course use larger one
+ newVisibleArea > originIntersectionVisibleArea ||
+ (newVisibleArea === originIntersectionVisibleArea &&
+ (!isVisibleFirst ||
+ // Choose recommend one
+ newVisibleRecommendArea >= originIntersectionRecommendArea))
+ ) {
+ prevFlipRef.current.tb = true;
+ nextOffsetY = tmpNextOffsetY;
+ popupOffsetY = -popupOffsetY;
+
+ nextAlignInfo.points = [
+ reversePoints(popupPoints, 0),
+ reversePoints(targetPoints, 0),
+ ];
+ } else {
+ prevFlipRef.current.tb = false;
+ }
+ }
+
+ // >>>>>>>>>> Left & Right
+ const needAdjustX = supportAdjust(adjustX);
+
+ // >>>>> Flip
+ const sameLR = popupPoints[1] === targetPoints[1];
+
+ // Right to Left
+ if (
+ needAdjustX &&
+ popupPoints[1] === 'l' &&
+ (nextPopupRight > adjustCheckVisibleArea.right ||
+ prevFlipRef.current.rl)
+ ) {
+ let tmpNextOffsetX: number = nextOffsetX;
+
+ if (sameLR) {
+ tmpNextOffsetX -= popupWidth - targetWidth;
+ } else {
+ tmpNextOffsetX =
+ targetAlignPointTL.x - popupAlignPointBR.x - popupOffsetX;
+ }
+
+ const newVisibleArea = getIntersectionVisibleArea(
+ tmpNextOffsetX,
+ nextOffsetY,
+ );
+ const newVisibleRecommendArea = getIntersectionVisibleArea(
+ tmpNextOffsetX,
+ nextOffsetY,
+ visibleRegionArea,
+ );
+
+ if (
+ // Of course use larger one
+ newVisibleArea > originIntersectionVisibleArea ||
+ (newVisibleArea === originIntersectionVisibleArea &&
+ (!isVisibleFirst ||
+ // Choose recommend one
+ newVisibleRecommendArea >= originIntersectionRecommendArea))
+ ) {
+ prevFlipRef.current.rl = true;
+ nextOffsetX = tmpNextOffsetX;
+ popupOffsetX = -popupOffsetX;
+
+ nextAlignInfo.points = [
+ reversePoints(popupPoints, 1),
+ reversePoints(targetPoints, 1),
+ ];
+ } else {
+ prevFlipRef.current.rl = false;
+ }
+ }
+
+ // Left to Right
+ if (
+ needAdjustX &&
+ popupPoints[1] === 'r' &&
+ (nextPopupX < adjustCheckVisibleArea.left || prevFlipRef.current.lr)
+ ) {
+ let tmpNextOffsetX: number = nextOffsetX;
+
+ if (sameLR) {
+ tmpNextOffsetX += popupWidth - targetWidth;
+ } else {
+ tmpNextOffsetX =
+ targetAlignPointBR.x - popupAlignPointTL.x - popupOffsetX;
+ }
+
+ const newVisibleArea = getIntersectionVisibleArea(
+ tmpNextOffsetX,
+ nextOffsetY,
+ );
+ const newVisibleRecommendArea = getIntersectionVisibleArea(
+ tmpNextOffsetX,
+ nextOffsetY,
+ visibleRegionArea,
+ );
+
+ if (
+ // Of course use larger one
+ newVisibleArea > originIntersectionVisibleArea ||
+ (newVisibleArea === originIntersectionVisibleArea &&
+ (!isVisibleFirst ||
+ // Choose recommend one
+ newVisibleRecommendArea >= originIntersectionRecommendArea))
+ ) {
+ prevFlipRef.current.lr = true;
+ nextOffsetX = tmpNextOffsetX;
+ popupOffsetX = -popupOffsetX;
+
+ nextAlignInfo.points = [
+ reversePoints(popupPoints, 1),
+ reversePoints(targetPoints, 1),
+ ];
+ } else {
+ prevFlipRef.current.lr = false;
+ }
+ }
+
+ // ============================ Shift ============================
+ syncNextPopupPosition();
+
+ const numShiftX = shiftX === true ? 0 : shiftX;
+ if (typeof numShiftX === 'number') {
+ // Left
+ if (nextPopupX < visibleRegionArea.left) {
+ nextOffsetX -= nextPopupX - visibleRegionArea.left - popupOffsetX;
+
+ if (targetRect.x + targetWidth < visibleRegionArea.left + numShiftX) {
+ nextOffsetX +=
+ targetRect.x - visibleRegionArea.left + targetWidth - numShiftX;
+ }
+ }
+
+ // Right
+ if (nextPopupRight > visibleRegionArea.right) {
+ nextOffsetX -=
+ nextPopupRight - visibleRegionArea.right - popupOffsetX;
+
+ if (targetRect.x > visibleRegionArea.right - numShiftX) {
+ nextOffsetX += targetRect.x - visibleRegionArea.right + numShiftX;
+ }
+ }
+ }
+
+ const numShiftY = shiftY === true ? 0 : shiftY;
+ if (typeof numShiftY === 'number') {
+ // Top
+ if (nextPopupY < visibleRegionArea.top) {
+ nextOffsetY -= nextPopupY - visibleRegionArea.top - popupOffsetY;
+
+ // When target if far away from visible area
+ // Stop shift
+ if (targetRect.y + targetHeight < visibleRegionArea.top + numShiftY) {
+ nextOffsetY +=
+ targetRect.y - visibleRegionArea.top + targetHeight - numShiftY;
+ }
+ }
+
+ // Bottom
+ if (nextPopupBottom > visibleRegionArea.bottom) {
+ nextOffsetY -=
+ nextPopupBottom - visibleRegionArea.bottom - popupOffsetY;
+
+ if (targetRect.y > visibleRegionArea.bottom - numShiftY) {
+ nextOffsetY += targetRect.y - visibleRegionArea.bottom + numShiftY;
+ }
+ }
+ }
+
+ // ============================ Arrow ============================
+ // Arrow center align
+ const popupLeft = popupRect.x + nextOffsetX;
+ const popupRight = popupLeft + popupWidth;
+ const popupTop = popupRect.y + nextOffsetY;
+ const popupBottom = popupTop + popupHeight;
+
+ const targetLeft = targetRect.x;
+ const targetRight = targetLeft + targetWidth;
+ const targetTop = targetRect.y;
+ const targetBottom = targetTop + targetHeight;
+
+ const maxLeft = Math.max(popupLeft, targetLeft);
+ const minRight = Math.min(popupRight, targetRight);
+
+ const xCenter = (maxLeft + minRight) / 2;
+ const nextArrowX = xCenter - popupLeft;
+
+ const maxTop = Math.max(popupTop, targetTop);
+ const minBottom = Math.min(popupBottom, targetBottom);
+
+ const yCenter = (maxTop + minBottom) / 2;
+ const nextArrowY = yCenter - popupTop;
+
+ onPopupAlign?.(popupEle, nextAlignInfo);
+
+ // Additional calculate right & bottom position
+ let offsetX4Right =
+ popupMirrorRect.right - popupRect.x - (nextOffsetX + popupRect.width);
+ let offsetY4Bottom =
+ popupMirrorRect.bottom - popupRect.y - (nextOffsetY + popupRect.height);
+
+ if (scaleX === 1) {
+ nextOffsetX = Math.round(nextOffsetX);
+ offsetX4Right = Math.round(offsetX4Right);
+ }
+
+ if (scaleY === 1) {
+ nextOffsetY = Math.round(nextOffsetY);
+ offsetY4Bottom = Math.round(offsetY4Bottom);
+ }
+
+ const nextOffsetInfo = {
+ ready: true,
+ offsetX: nextOffsetX / scaleX,
+ offsetY: nextOffsetY / scaleY,
+ offsetR: offsetX4Right / scaleX,
+ offsetB: offsetY4Bottom / scaleY,
+ arrowX: nextArrowX / scaleX,
+ arrowY: nextArrowY / scaleY,
+ scaleX,
+ scaleY,
+ align: nextAlignInfo,
+ };
+
+ setOffsetInfo(nextOffsetInfo);
+ }
+ });
+
+ const triggerAlign = () => {
+ alignCountRef.current += 1;
+ const id = alignCountRef.current;
+
+ // Merge all align requirement into one frame
+ Promise.resolve().then(() => {
+ if (alignCountRef.current === id) {
+ onAlign();
+ }
+ });
+ };
+
+ // Reset ready status when placement & open changed
+ const resetReady = () => {
+ setOffsetInfo((ori) => ({
+ ...ori,
+ ready: false,
+ }));
+ };
+
+ useLayoutEffect(resetReady, [placement]);
+
+ useLayoutEffect(() => {
+ if (!open) {
+ resetReady();
+ }
+ }, [open]);
+
+ return [
+ offsetInfo.ready,
+ offsetInfo.offsetX,
+ offsetInfo.offsetY,
+ offsetInfo.offsetR,
+ offsetInfo.offsetB,
+ offsetInfo.arrowX,
+ offsetInfo.arrowY,
+ offsetInfo.scaleX,
+ offsetInfo.scaleY,
+ offsetInfo.align,
+ triggerAlign,
+ ];
+}
diff --git a/src/hooks/useWatch.ts b/src/hooks/useWatch.ts
new file mode 100644
index 0000000..eba0fd8
--- /dev/null
+++ b/src/hooks/useWatch.ts
@@ -0,0 +1,48 @@
+import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect';
+import { collectScroller, getWin } from '../util';
+
+export default function useWatch(
+ open: boolean,
+ target: HTMLElement,
+ popup: HTMLElement,
+ onAlign: VoidFunction,
+ onScroll: VoidFunction,
+) {
+ useLayoutEffect(() => {
+ if (open && target && popup) {
+ const targetElement = target;
+ const popupElement = popup;
+ const targetScrollList = collectScroller(targetElement);
+ const popupScrollList = collectScroller(popupElement);
+
+ const win = getWin(popupElement);
+
+ const mergedList = new Set([
+ win,
+ ...targetScrollList,
+ ...popupScrollList,
+ ]);
+
+ function notifyScroll() {
+ onAlign();
+ onScroll();
+ }
+
+ mergedList.forEach((scroller) => {
+ scroller.addEventListener('scroll', notifyScroll, { passive: true });
+ });
+
+ win.addEventListener('resize', notifyScroll, { passive: true });
+
+ // First time always do align
+ onAlign();
+
+ return () => {
+ mergedList.forEach((scroller) => {
+ scroller.removeEventListener('scroll', notifyScroll);
+ win.removeEventListener('resize', notifyScroll);
+ });
+ };
+ }
+ }, [open, target, popup]);
+}
diff --git a/src/hooks/useWinClick.ts b/src/hooks/useWinClick.ts
new file mode 100644
index 0000000..f33f05b
--- /dev/null
+++ b/src/hooks/useWinClick.ts
@@ -0,0 +1,70 @@
+import { getShadowRoot } from 'rc-util/lib/Dom/shadow';
+import { warning } from 'rc-util/lib/warning';
+import * as React from 'react';
+import { getWin } from '../util';
+
+export default function useWinClick(
+ open: boolean,
+ clickToHide: boolean,
+ targetEle: HTMLElement,
+ popupEle: HTMLElement,
+ mask: boolean,
+ maskClosable: boolean,
+ inPopupOrChild: (target: EventTarget) => boolean,
+ triggerOpen: (open: boolean) => void,
+) {
+ const openRef = React.useRef(open);
+ openRef.current = open;
+
+ // Click to hide is special action since click popup element should not hide
+ React.useEffect(() => {
+ if (clickToHide && popupEle && (!mask || maskClosable)) {
+ const onTriggerClose = ({ target }: MouseEvent) => {
+ if (openRef.current && !inPopupOrChild(target)) {
+ triggerOpen(false);
+ }
+ };
+
+ const win = getWin(popupEle);
+
+ win.addEventListener('mousedown', onTriggerClose, true);
+ win.addEventListener('contextmenu', onTriggerClose, true);
+
+ // shadow root
+ const targetShadowRoot = getShadowRoot(targetEle);
+ if (targetShadowRoot) {
+ targetShadowRoot.addEventListener('mousedown', onTriggerClose, true);
+ targetShadowRoot.addEventListener('contextmenu', onTriggerClose, true);
+ }
+
+ // Warning if target and popup not in same root
+ if (process.env.NODE_ENV !== 'production') {
+ const targetRoot = targetEle?.getRootNode?.();
+ const popupRoot = popupEle.getRootNode?.();
+
+ warning(
+ targetRoot === popupRoot,
+ `trigger element and popup element should in same shadow root.`,
+ );
+ }
+
+ return () => {
+ win.removeEventListener('mousedown', onTriggerClose, true);
+ win.removeEventListener('contextmenu', onTriggerClose, true);
+
+ if (targetShadowRoot) {
+ targetShadowRoot.removeEventListener(
+ 'mousedown',
+ onTriggerClose,
+ true,
+ );
+ targetShadowRoot.removeEventListener(
+ 'contextmenu',
+ onTriggerClose,
+ true,
+ );
+ }
+ };
+ }
+ }, [clickToHide, targetEle, popupEle, mask, maskClosable]);
+}
diff --git a/src/index.tsx b/src/index.tsx
new file mode 100644
index 0000000..4a7b990
--- /dev/null
+++ b/src/index.tsx
@@ -0,0 +1,763 @@
+import Portal from '@rc-component/portal';
+import classNames from 'classnames';
+import type { CSSMotionProps } from 'rc-motion';
+import ResizeObserver from 'rc-resize-observer';
+import { isDOM } from 'rc-util/lib/Dom/findDOMNode';
+import { getShadowRoot } from 'rc-util/lib/Dom/shadow';
+import useEvent from 'rc-util/lib/hooks/useEvent';
+import useId from 'rc-util/lib/hooks/useId';
+import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect';
+import isMobile from 'rc-util/lib/isMobile';
+import * as React from 'react';
+import Popup from './Popup';
+import TriggerWrapper from './TriggerWrapper';
+import type { TriggerContextProps } from './context';
+import TriggerContext from './context';
+import useAction from './hooks/useAction';
+import useAlign from './hooks/useAlign';
+import useWatch from './hooks/useWatch';
+import useWinClick from './hooks/useWinClick';
+import type {
+ ActionType,
+ AlignType,
+ AnimationType,
+ ArrowPos,
+ ArrowTypeOuter,
+ BuildInPlacements,
+ TransitionNameType,
+} from './interface';
+import { getAlignPopupClassName, getMotion } from './util';
+
+export type {
+ ActionType,
+ AlignType,
+ ArrowTypeOuter as ArrowType,
+ BuildInPlacements,
+};
+
+export interface TriggerRef {
+ nativeElement: HTMLElement;
+ popupElement: HTMLDivElement;
+ forceAlign: VoidFunction;
+}
+
+// Removed Props List
+// Seems this can be auto
+// getDocument?: (element?: HTMLElement) => Document;
+
+// New version will not wrap popup with `rc-trigger-popup-content` when multiple children
+
+export interface TriggerProps {
+ children: React.ReactElement;
+ action?: ActionType | ActionType[];
+ showAction?: ActionType[];
+ hideAction?: ActionType[];
+
+ prefixCls?: string;
+
+ zIndex?: number;
+
+ onPopupAlign?: (element: HTMLElement, align: AlignType) => void;
+
+ stretch?: string;
+
+ // ==================== Open =====================
+ popupVisible?: boolean;
+ defaultPopupVisible?: boolean;
+ onPopupVisibleChange?: (visible: boolean) => void;
+ afterPopupVisibleChange?: (visible: boolean) => void;
+
+ // =================== Portal ====================
+ getPopupContainer?: (node: HTMLElement) => HTMLElement;
+ forceRender?: boolean;
+ autoDestroy?: boolean;
+
+ /** @deprecated Please use `autoDestroy` instead */
+ destroyPopupOnHide?: boolean;
+
+ // ==================== Mask =====================
+ mask?: boolean;
+ maskClosable?: boolean;
+
+ // =================== Motion ====================
+ /** Set popup motion. You can ref `rc-motion` for more info. */
+ popupMotion?: CSSMotionProps;
+ /** Set mask motion. You can ref `rc-motion` for more info. */
+ maskMotion?: CSSMotionProps;
+
+ /** @deprecated Please us `popupMotion` instead. */
+ popupTransitionName?: TransitionNameType;
+ /** @deprecated Please us `popupMotion` instead. */
+ popupAnimation?: AnimationType;
+ /** @deprecated Please us `maskMotion` instead. */
+ maskTransitionName?: TransitionNameType;
+ /** @deprecated Please us `maskMotion` instead. */
+ maskAnimation?: AnimationType;
+
+ // ==================== Delay ====================
+ mouseEnterDelay?: number;
+ mouseLeaveDelay?: number;
+
+ focusDelay?: number;
+ blurDelay?: number;
+
+ // ==================== Popup ====================
+ popup: React.ReactNode | (() => React.ReactNode);
+ popupPlacement?: string;
+ builtinPlacements?: BuildInPlacements;
+ popupAlign?: AlignType;
+ popupClassName?: string;
+ popupStyle?: React.CSSProperties;
+ getPopupClassNameFromAlign?: (align: AlignType) => string;
+ onPopupClick?: React.MouseEventHandler;
+
+ alignPoint?: boolean; // Maybe we can support user pass position in the future
+
+ /**
+ * Trigger will memo content when close.
+ * This may affect the case if want to keep content update.
+ * Set `fresh` to `false` will always keep update.
+ */
+ fresh?: boolean;
+
+ // ==================== Arrow ====================
+ arrow?: boolean | ArrowTypeOuter;
+
+ // ================= Deprecated ==================
+ /** @deprecated Add `className` on `children`. Please add `className` directly instead. */
+ className?: string;
+
+ // =================== Private ===================
+ /**
+ * @private Get trigger DOM node.
+ * Used for some component is function component which can not access by `findDOMNode`
+ */
+ getTriggerDOMNode?: (node: React.ReactInstance) => HTMLElement;
+
+ // // ========================== Mobile ==========================
+ // /** @private Bump fixed position at bottom in mobile.
+ // * This is internal usage currently, do not use in your prod */
+ // mobile?: MobileConfig;
+}
+
+export function generateTrigger(
+ PortalComponent: React.ComponentType = Portal,
+) {
+ const Trigger = React.forwardRef((props, ref) => {
+ const {
+ prefixCls = 'rc-trigger-popup',
+ children,
+
+ // Action
+ action = 'hover',
+ showAction,
+ hideAction,
+
+ // Open
+ popupVisible,
+ defaultPopupVisible,
+ onPopupVisibleChange,
+ afterPopupVisibleChange,
+
+ // Delay
+ mouseEnterDelay,
+ mouseLeaveDelay = 0.1,
+
+ focusDelay,
+ blurDelay,
+
+ // Mask
+ mask,
+ maskClosable = true,
+
+ // Portal
+ getPopupContainer,
+ forceRender,
+ autoDestroy,
+ destroyPopupOnHide,
+
+ // Popup
+ popup,
+ popupClassName,
+ popupStyle,
+
+ popupPlacement,
+ builtinPlacements = {},
+ popupAlign,
+ zIndex,
+ stretch,
+ getPopupClassNameFromAlign,
+ fresh,
+
+ alignPoint,
+
+ onPopupClick,
+ onPopupAlign,
+
+ // Arrow
+ arrow,
+
+ // Motion
+ popupMotion,
+ maskMotion,
+ popupTransitionName,
+ popupAnimation,
+ maskTransitionName,
+ maskAnimation,
+
+ // Deprecated
+ className,
+
+ // Private
+ getTriggerDOMNode,
+
+ ...restProps
+ } = props;
+
+ const mergedAutoDestroy = autoDestroy || destroyPopupOnHide || false;
+
+ // =========================== Mobile ===========================
+ const [mobile, setMobile] = React.useState(false);
+ useLayoutEffect(() => {
+ setMobile(isMobile());
+ }, []);
+
+ // ========================== Context ===========================
+ const subPopupElements = React.useRef>({});
+
+ const parentContext = React.useContext(TriggerContext);
+ const context = React.useMemo(() => {
+ return {
+ registerSubPopup: (id, subPopupEle) => {
+ subPopupElements.current[id] = subPopupEle;
+
+ parentContext?.registerSubPopup(id, subPopupEle);
+ },
+ };
+ }, [parentContext]);
+
+ // =========================== Popup ============================
+ const id = useId();
+ const [popupEle, setPopupEle] = React.useState(null);
+
+ // Used for forwardRef popup. Not use internal
+ const externalPopupRef = React.useRef(null);
+
+ const setPopupRef = useEvent((node: HTMLDivElement) => {
+ externalPopupRef.current = node;
+
+ if (isDOM(node) && popupEle !== node) {
+ setPopupEle(node);
+ }
+
+ parentContext?.registerSubPopup(id, node);
+ });
+
+ // =========================== Target ===========================
+ // Use state to control here since `useRef` update not trigger render
+ const [targetEle, setTargetEle] = React.useState(null);
+
+ // Used for forwardRef target. Not use internal
+ const externalForwardRef = React.useRef(null);
+
+ const setTargetRef = useEvent((node: HTMLElement) => {
+ if (isDOM(node) && targetEle !== node) {
+ setTargetEle(node);
+ externalForwardRef.current = node;
+ }
+ });
+
+ // ========================== Children ==========================
+ const child = React.Children.only(children) as React.ReactElement;
+ const originChildProps = child?.props || {};
+ const cloneProps: typeof originChildProps = {};
+
+ const inPopupOrChild = useEvent((ele: any) => {
+ const childDOM = targetEle;
+
+ return (
+ childDOM?.contains(ele) ||
+ getShadowRoot(childDOM)?.host === ele ||
+ ele === childDOM ||
+ popupEle?.contains(ele) ||
+ getShadowRoot(popupEle)?.host === ele ||
+ ele === popupEle ||
+ Object.values(subPopupElements.current).some(
+ (subPopupEle) => subPopupEle?.contains(ele) || ele === subPopupEle,
+ )
+ );
+ });
+
+ // =========================== Motion ===========================
+ const mergePopupMotion = getMotion(
+ prefixCls,
+ popupMotion,
+ popupAnimation,
+ popupTransitionName,
+ );
+
+ const mergeMaskMotion = getMotion(
+ prefixCls,
+ maskMotion,
+ maskAnimation,
+ maskTransitionName,
+ );
+
+ // ============================ Open ============================
+ const [internalOpen, setInternalOpen] = React.useState(
+ defaultPopupVisible || false,
+ );
+
+ // Render still use props as first priority
+ const mergedOpen = popupVisible ?? internalOpen;
+
+ // We use effect sync here in case `popupVisible` back to `undefined`
+ const setMergedOpen = useEvent((nextOpen: boolean) => {
+ if (popupVisible === undefined) {
+ setInternalOpen(nextOpen);
+ }
+ });
+
+ useLayoutEffect(() => {
+ setInternalOpen(popupVisible || false);
+ }, [popupVisible]);
+
+ const openRef = React.useRef(mergedOpen);
+ openRef.current = mergedOpen;
+
+ const lastTriggerRef = React.useRef([]);
+ lastTriggerRef.current = [];
+
+ const internalTriggerOpen = useEvent((nextOpen: boolean) => {
+ setMergedOpen(nextOpen);
+
+ // Enter or Pointer will both trigger open state change
+ // We only need take one to avoid duplicated change event trigger
+ // Use `lastTriggerRef` to record last open type
+ if (
+ (lastTriggerRef.current[lastTriggerRef.current.length - 1] ??
+ mergedOpen) !== nextOpen
+ ) {
+ lastTriggerRef.current.push(nextOpen);
+ onPopupVisibleChange?.(nextOpen);
+ }
+ });
+
+ // Trigger for delay
+ const delayRef = React.useRef();
+
+ const clearDelay = () => {
+ clearTimeout(delayRef.current);
+ };
+
+ const triggerOpen = (nextOpen: boolean, delay = 0) => {
+ clearDelay();
+
+ if (delay === 0) {
+ internalTriggerOpen(nextOpen);
+ } else {
+ delayRef.current = setTimeout(() => {
+ internalTriggerOpen(nextOpen);
+ }, delay * 1000);
+ }
+ };
+
+ React.useEffect(() => clearDelay, []);
+
+ // ========================== Motion ============================
+ const [inMotion, setInMotion] = React.useState(false);
+
+ useLayoutEffect(
+ (firstMount) => {
+ if (!firstMount || mergedOpen) {
+ setInMotion(true);
+ }
+ },
+ [mergedOpen],
+ );
+
+ const [motionPrepareResolve, setMotionPrepareResolve] =
+ React.useState(null);
+
+ // =========================== Align ============================
+ const [mousePos, setMousePos] = React.useState<[x: number, y: number]>([
+ 0, 0,
+ ]);
+
+ const setMousePosByEvent = (
+ event: Pick,
+ ) => {
+ setMousePos([event.clientX, event.clientY]);
+ };
+
+ const [
+ ready,
+ offsetX,
+ offsetY,
+ offsetR,
+ offsetB,
+ arrowX,
+ arrowY,
+ scaleX,
+ scaleY,
+ alignInfo,
+ onAlign,
+ ] = useAlign(
+ mergedOpen,
+ popupEle,
+ alignPoint ? mousePos : targetEle,
+ popupPlacement,
+ builtinPlacements,
+ popupAlign,
+ onPopupAlign,
+ );
+
+ const [showActions, hideActions] = useAction(
+ mobile,
+ action,
+ showAction,
+ hideAction,
+ );
+
+ const clickToShow = showActions.has('click');
+ const clickToHide =
+ hideActions.has('click') || hideActions.has('contextMenu');
+
+ const triggerAlign = useEvent(() => {
+ if (!inMotion) {
+ onAlign();
+ }
+ });
+
+ const onScroll = () => {
+ if (openRef.current && alignPoint && clickToHide) {
+ triggerOpen(false);
+ }
+ };
+
+ useWatch(mergedOpen, targetEle, popupEle, triggerAlign, onScroll);
+
+ useLayoutEffect(() => {
+ triggerAlign();
+ }, [mousePos, popupPlacement]);
+
+ // When no builtinPlacements and popupAlign changed
+ useLayoutEffect(() => {
+ if (mergedOpen && !builtinPlacements?.[popupPlacement]) {
+ triggerAlign();
+ }
+ }, [JSON.stringify(popupAlign)]);
+
+ const alignedClassName = React.useMemo(() => {
+ const baseClassName = getAlignPopupClassName(
+ builtinPlacements,
+ prefixCls,
+ alignInfo,
+ alignPoint,
+ );
+
+ return classNames(baseClassName, getPopupClassNameFromAlign?.(alignInfo));
+ }, [
+ alignInfo,
+ getPopupClassNameFromAlign,
+ builtinPlacements,
+ prefixCls,
+ alignPoint,
+ ]);
+
+ // ============================ Refs ============================
+ React.useImperativeHandle(ref, () => ({
+ nativeElement: externalForwardRef.current,
+ popupElement: externalPopupRef.current,
+ forceAlign: triggerAlign,
+ }));
+
+ // ========================== Stretch ===========================
+ const [targetWidth, setTargetWidth] = React.useState(0);
+ const [targetHeight, setTargetHeight] = React.useState(0);
+
+ const syncTargetSize = () => {
+ if (stretch && targetEle) {
+ const rect = targetEle.getBoundingClientRect();
+ setTargetWidth(rect.width);
+ setTargetHeight(rect.height);
+ }
+ };
+
+ const onTargetResize = () => {
+ syncTargetSize();
+ triggerAlign();
+ };
+
+ // ========================== Motion ============================
+ const onVisibleChanged = (visible: boolean) => {
+ setInMotion(false);
+ onAlign();
+ afterPopupVisibleChange?.(visible);
+ };
+
+ // We will trigger align when motion is in prepare
+ const onPrepare = () =>
+ new Promise((resolve) => {
+ syncTargetSize();
+ setMotionPrepareResolve(() => resolve);
+ });
+
+ useLayoutEffect(() => {
+ if (motionPrepareResolve) {
+ onAlign();
+ motionPrepareResolve();
+ setMotionPrepareResolve(null);
+ }
+ }, [motionPrepareResolve]);
+
+ // =========================== Action ===========================
+ /**
+ * Util wrapper for trigger action
+ */
+ function wrapperAction(
+ eventName: string,
+ nextOpen: boolean,
+ delay?: number,
+ preEvent?: (event: Event) => void,
+ ) {
+ cloneProps[eventName] = (event: any, ...args: any[]) => {
+ preEvent?.(event);
+ triggerOpen(nextOpen, delay);
+
+ // Pass to origin
+ originChildProps[eventName]?.(event, ...args);
+ };
+ }
+
+ // ======================= Action: Click ========================
+ if (clickToShow || clickToHide) {
+ cloneProps.onClick = (
+ event: React.MouseEvent,
+ ...args: any[]
+ ) => {
+ if (openRef.current && clickToHide) {
+ triggerOpen(false);
+ } else if (!openRef.current && clickToShow) {
+ setMousePosByEvent(event);
+ triggerOpen(true);
+ }
+
+ // Pass to origin
+ originChildProps.onClick?.(event, ...args);
+ };
+ }
+
+ // Click to hide is special action since click popup element should not hide
+ useWinClick(
+ mergedOpen,
+ clickToHide,
+ targetEle,
+ popupEle,
+ mask,
+ maskClosable,
+ inPopupOrChild,
+ triggerOpen,
+ );
+
+ // ======================= Action: Hover ========================
+ const hoverToShow = showActions.has('hover');
+ const hoverToHide = hideActions.has('hover');
+
+ let onPopupMouseEnter: React.MouseEventHandler;
+ let onPopupMouseLeave: VoidFunction;
+
+ if (hoverToShow) {
+ // Compatible with old browser which not support pointer event
+ wrapperAction(
+ 'onMouseEnter',
+ true,
+ mouseEnterDelay,
+ (event) => {
+ setMousePosByEvent(event);
+ },
+ );
+ wrapperAction(
+ 'onPointerEnter',
+ true,
+ mouseEnterDelay,
+ (event) => {
+ setMousePosByEvent(event);
+ },
+ );
+ onPopupMouseEnter = (event) => {
+ // Only trigger re-open when popup is visible
+ if (
+ (mergedOpen || inMotion) &&
+ popupEle?.contains(event.target as HTMLElement)
+ ) {
+ triggerOpen(true, mouseEnterDelay);
+ }
+ };
+
+ // Align Point
+ if (alignPoint) {
+ cloneProps.onMouseMove = (event: React.MouseEvent) => {
+ // setMousePosByEvent(event);
+ originChildProps.onMouseMove?.(event);
+ };
+ }
+ }
+
+ if (hoverToHide) {
+ wrapperAction('onMouseLeave', false, mouseLeaveDelay);
+ wrapperAction('onPointerLeave', false, mouseLeaveDelay);
+ onPopupMouseLeave = () => {
+ triggerOpen(false, mouseLeaveDelay);
+ };
+ }
+
+ // ======================= Action: Focus ========================
+ if (showActions.has('focus')) {
+ wrapperAction('onFocus', true, focusDelay);
+ }
+
+ if (hideActions.has('focus')) {
+ wrapperAction('onBlur', false, blurDelay);
+ }
+
+ // ==================== Action: ContextMenu =====================
+ if (showActions.has('contextMenu')) {
+ cloneProps.onContextMenu = (event: React.MouseEvent, ...args: any[]) => {
+ if (openRef.current && hideActions.has('contextMenu')) {
+ triggerOpen(false);
+ } else {
+ setMousePosByEvent(event);
+ triggerOpen(true);
+ }
+
+ event.preventDefault();
+
+ // Pass to origin
+ originChildProps.onContextMenu?.(event, ...args);
+ };
+ }
+
+ // ========================= ClassName ==========================
+ if (className) {
+ cloneProps.className = classNames(originChildProps.className, className);
+ }
+
+ // =========================== Render ===========================
+ const mergedChildrenProps = {
+ ...originChildProps,
+ ...cloneProps,
+ };
+
+ // Pass props into cloneProps for nest usage
+ const passedProps: Record = {};
+ const passedEventList = [
+ 'onContextMenu',
+ 'onClick',
+ 'onMouseDown',
+ 'onTouchStart',
+ 'onMouseEnter',
+ 'onMouseLeave',
+ 'onFocus',
+ 'onBlur',
+ ];
+
+ passedEventList.forEach((eventName) => {
+ if (restProps[eventName]) {
+ passedProps[eventName] = (...args: any[]) => {
+ mergedChildrenProps[eventName]?.(...args);
+ restProps[eventName](...args);
+ };
+ }
+ });
+
+ // Child Node
+ const triggerNode = React.cloneElement(child, {
+ ...mergedChildrenProps,
+ ...passedProps,
+ });
+
+ const arrowPos: ArrowPos = {
+ x: arrowX,
+ y: arrowY,
+ };
+
+ const innerArrow: ArrowTypeOuter = arrow
+ ? {
+ // true and Object likely
+ ...(arrow !== true ? arrow : {}),
+ }
+ : null;
+
+ // Render
+ return (
+ <>
+
+
+ {triggerNode}
+
+
+
+
+
+ >
+ );
+ });
+
+ if (process.env.NODE_ENV !== 'production') {
+ Trigger.displayName = 'Trigger';
+ }
+
+ return Trigger;
+}
+
+export default generateTrigger(Portal);
diff --git a/src/interface.ts b/src/interface.ts
new file mode 100644
index 0000000..ddb27ee
--- /dev/null
+++ b/src/interface.ts
@@ -0,0 +1,128 @@
+import type { CSSMotionProps } from 'rc-motion';
+
+export type Placement =
+ | 'top'
+ | 'left'
+ | 'right'
+ | 'bottom'
+ | 'topLeft'
+ | 'topRight'
+ | 'bottomLeft'
+ | 'bottomRight'
+ | 'leftTop'
+ | 'leftBottom'
+ | 'rightTop'
+ | 'rightBottom';
+
+export type AlignPointTopBottom = 't' | 'b' | 'c';
+export type AlignPointLeftRight = 'l' | 'r' | 'c';
+
+/** Two char of 't' 'b' 'c' 'l' 'r'. Example: 'lt' */
+export type AlignPoint = `${AlignPointTopBottom}${AlignPointLeftRight}`;
+
+export type OffsetType = number | `${number}%`;
+
+export interface AlignType {
+ /**
+ * move point of source node to align with point of target node.
+ * Such as ['tr','cc'], align top right point of source node with center point of target node.
+ * Point can be 't'(top), 'b'(bottom), 'c'(center), 'l'(left), 'r'(right) */
+ points?: (string | AlignPoint)[];
+
+ /**
+ * @private Do not use in your production code
+ */
+ _experimental?: Record;
+
+ /**
+ * offset source node by offset[0] in x and offset[1] in y.
+ * If offset contains percentage string value, it is relative to sourceNode region.
+ */
+ offset?: OffsetType[];
+ /**
+ * offset target node by offset[0] in x and offset[1] in y.
+ * If targetOffset contains percentage string value, it is relative to targetNode region.
+ */
+ targetOffset?: OffsetType[];
+ /**
+ * If adjustX field is true, will adjust source node in x direction if source node is invisible.
+ * If adjustY field is true, will adjust source node in y direction if source node is invisible.
+ */
+ overflow?: {
+ adjustX?: boolean | number;
+ adjustY?: boolean | number;
+ shiftX?: boolean | number;
+ shiftY?: boolean | number;
+ };
+ /** Auto adjust arrow position */
+ autoArrow?: boolean;
+ /**
+ * Config visible region check of html node. Default `visible`:
+ * - `visible`:
+ * The visible region of user browser window.
+ * Use `clientHeight` for check.
+ * If `visible` region not satisfy, fallback to `scroll`.
+ * - `scroll`:
+ * The whole region of the html scroll area.
+ * Use `scrollHeight` for check.
+ * - `visibleFirst`:
+ * Similar to `visible`, but if `visible` region not satisfy, fallback to `scroll`.
+ */
+ htmlRegion?: 'visible' | 'scroll' | 'visibleFirst';
+
+ /**
+ * Auto chose position with `top` or `bottom` by the align result
+ */
+ dynamicInset?: boolean;
+ /**
+ * Whether use css right instead of left to position
+ */
+ useCssRight?: boolean;
+ /**
+ * Whether use css bottom instead of top to position
+ */
+ useCssBottom?: boolean;
+ /**
+ * Whether use css transform instead of left/top/right/bottom to position if browser supports.
+ * Defaults to false.
+ */
+ useCssTransform?: boolean;
+ ignoreShake?: boolean;
+}
+
+export interface ArrowTypeOuter {
+ className?: string;
+ content?: React.ReactNode;
+}
+
+export type ArrowPos = {
+ x?: number;
+ y?: number;
+};
+
+export type BuildInPlacements = Record;
+
+export type StretchType = string;
+
+export type ActionType = 'hover' | 'focus' | 'click' | 'contextMenu';
+
+export type AnimationType = string;
+
+export type TransitionNameType = string;
+
+export interface Point {
+ pageX: number;
+ pageY: number;
+}
+
+export interface CommonEventHandler {
+ remove: () => void;
+}
+
+export interface MobileConfig {
+ /** Set popup motion. You can ref `rc-motion` for more info. */
+ popupMotion?: CSSMotionProps;
+ popupClassName?: string;
+ popupStyle?: React.CSSProperties;
+ popupRender?: (originNode: React.ReactNode) => React.ReactNode;
+}
diff --git a/src/mock.tsx b/src/mock.tsx
new file mode 100644
index 0000000..d5d088a
--- /dev/null
+++ b/src/mock.tsx
@@ -0,0 +1,34 @@
+import * as React from 'react';
+import { generateTrigger } from './index';
+
+interface MockPortalProps {
+ open?: boolean;
+ autoDestroy?: boolean;
+ children: React.ReactElement;
+ getContainer?: () => HTMLElement;
+}
+
+const MockPortal: React.FC = ({
+ open,
+ autoDestroy,
+ children,
+ getContainer,
+}) => {
+ const [visible, setVisible] = React.useState(open);
+
+ React.useEffect(() => {
+ getContainer?.();
+ });
+
+ React.useEffect(() => {
+ if (open) {
+ setVisible(true);
+ } else if (autoDestroy) {
+ setVisible(false);
+ }
+ }, [open, autoDestroy]);
+
+ return visible ? children : null;
+};
+
+export default generateTrigger(MockPortal);
diff --git a/src/util.ts b/src/util.ts
new file mode 100644
index 0000000..5170d86
--- /dev/null
+++ b/src/util.ts
@@ -0,0 +1,222 @@
+import type { CSSMotionProps } from 'rc-motion';
+import type {
+ AlignType,
+ AnimationType,
+ BuildInPlacements,
+ TransitionNameType,
+} from './interface';
+
+function isPointsEq(
+ a1: string[] = [],
+ a2: string[] = [],
+ isAlignPoint: boolean,
+): boolean {
+ if (isAlignPoint) {
+ return a1[0] === a2[0];
+ }
+ return a1[0] === a2[0] && a1[1] === a2[1];
+}
+
+export function getAlignPopupClassName(
+ builtinPlacements: BuildInPlacements,
+ prefixCls: string,
+ align: AlignType,
+ isAlignPoint: boolean,
+): string {
+ const { points } = align;
+
+ const placements = Object.keys(builtinPlacements);
+
+ for (let i = 0; i < placements.length; i += 1) {
+ const placement = placements[i];
+ if (
+ isPointsEq(builtinPlacements[placement]?.points, points, isAlignPoint)
+ ) {
+ return `${prefixCls}-placement-${placement}`;
+ }
+ }
+
+ return '';
+}
+
+/** @deprecated We should not use this if we can refactor all deps */
+export function getMotion(
+ prefixCls: string,
+ motion: CSSMotionProps,
+ animation: AnimationType,
+ transitionName: TransitionNameType,
+): CSSMotionProps {
+ if (motion) {
+ return motion;
+ }
+
+ if (animation) {
+ return {
+ motionName: `${prefixCls}-${animation}`,
+ };
+ }
+
+ if (transitionName) {
+ return {
+ motionName: transitionName,
+ };
+ }
+
+ return null;
+}
+
+export function getWin(ele: HTMLElement) {
+ return ele.ownerDocument.defaultView;
+}
+
+/**
+ * Get all the scrollable parent elements of the element
+ * @param ele The element to be detected
+ * @param areaOnly Only return the parent which will cut visible area
+ */
+export function collectScroller(ele: HTMLElement) {
+ const scrollerList: HTMLElement[] = [];
+ let current = ele?.parentElement;
+
+ const scrollStyle = ['hidden', 'scroll', 'clip', 'auto'];
+
+ while (current) {
+ const { overflowX, overflowY, overflow } =
+ getWin(current).getComputedStyle(current);
+ if ([overflowX, overflowY, overflow].some((o) => scrollStyle.includes(o))) {
+ scrollerList.push(current);
+ }
+
+ current = current.parentElement;
+ }
+
+ return scrollerList;
+}
+
+export function toNum(num: number, defaultValue = 1) {
+ return Number.isNaN(num) ? defaultValue : num;
+}
+
+function getPxValue(val: string) {
+ return toNum(parseFloat(val), 0);
+}
+
+export interface VisibleArea {
+ left: number;
+ top: number;
+ right: number;
+ bottom: number;
+}
+
+/**
+ *
+ *
+ * **************************************
+ * * Border *
+ * * ************************** *
+ * * * * * *
+ * * B * * S * B *
+ * * o * * c * o *
+ * * r * Content * r * r *
+ * * d * * o * d *
+ * * e * * l * e *
+ * * r ******************** l * r *
+ * * * Scroll * *
+ * * ************************** *
+ * * Border *
+ * **************************************
+ *
+ */
+/**
+ * Get visible area of element
+ */
+export function getVisibleArea(
+ initArea: VisibleArea,
+ scrollerList?: HTMLElement[],
+) {
+ const visibleArea = { ...initArea };
+
+ (scrollerList || []).forEach((ele) => {
+ if (ele instanceof HTMLBodyElement || ele instanceof HTMLHtmlElement) {
+ return;
+ }
+
+ // Skip if static position which will not affect visible area
+ const {
+ overflow,
+ overflowClipMargin,
+ borderTopWidth,
+ borderBottomWidth,
+ borderLeftWidth,
+ borderRightWidth,
+ } = getWin(ele).getComputedStyle(ele);
+
+ const eleRect = ele.getBoundingClientRect();
+ const {
+ offsetHeight: eleOutHeight,
+ clientHeight: eleInnerHeight,
+ offsetWidth: eleOutWidth,
+ clientWidth: eleInnerWidth,
+ } = ele;
+
+ const borderTopNum = getPxValue(borderTopWidth);
+ const borderBottomNum = getPxValue(borderBottomWidth);
+ const borderLeftNum = getPxValue(borderLeftWidth);
+ const borderRightNum = getPxValue(borderRightWidth);
+
+ const scaleX = toNum(
+ Math.round((eleRect.width / eleOutWidth) * 1000) / 1000,
+ );
+ const scaleY = toNum(
+ Math.round((eleRect.height / eleOutHeight) * 1000) / 1000,
+ );
+
+ // Original visible area
+ const eleScrollWidth =
+ (eleOutWidth - eleInnerWidth - borderLeftNum - borderRightNum) * scaleX;
+ const eleScrollHeight =
+ (eleOutHeight - eleInnerHeight - borderTopNum - borderBottomNum) * scaleY;
+
+ // Cut border size
+ const scaledBorderTopWidth = borderTopNum * scaleY;
+ const scaledBorderBottomWidth = borderBottomNum * scaleY;
+ const scaledBorderLeftWidth = borderLeftNum * scaleX;
+ const scaledBorderRightWidth = borderRightNum * scaleX;
+
+ // Clip margin
+ let clipMarginWidth = 0;
+ let clipMarginHeight = 0;
+ if (overflow === 'clip') {
+ const clipNum = getPxValue(overflowClipMargin);
+ clipMarginWidth = clipNum * scaleX;
+ clipMarginHeight = clipNum * scaleY;
+ }
+
+ // Region
+ const eleLeft = eleRect.x + scaledBorderLeftWidth - clipMarginWidth;
+ const eleTop = eleRect.y + scaledBorderTopWidth - clipMarginHeight;
+
+ const eleRight =
+ eleLeft +
+ eleRect.width +
+ 2 * clipMarginWidth -
+ scaledBorderLeftWidth -
+ scaledBorderRightWidth -
+ eleScrollWidth;
+
+ const eleBottom =
+ eleTop +
+ eleRect.height +
+ 2 * clipMarginHeight -
+ scaledBorderTopWidth -
+ scaledBorderBottomWidth -
+ eleScrollHeight;
+
+ visibleArea.left = Math.max(visibleArea.left, eleLeft);
+ visibleArea.top = Math.max(visibleArea.top, eleTop);
+ visibleArea.right = Math.min(visibleArea.right, eleRight);
+ visibleArea.bottom = Math.min(visibleArea.bottom, eleBottom);
+ });
+
+ return visibleArea;
+}
diff --git a/tests/__snapshots__/mobile.test.tsx.snap b/tests/__snapshots__/mobile.test.tsx.snap
new file mode 100644
index 0000000..32c0894
--- /dev/null
+++ b/tests/__snapshots__/mobile.test.tsx.snap
@@ -0,0 +1,14 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Trigger.Mobile popupRender 1`] = `
+
+`;
diff --git a/tests/align.test.tsx b/tests/align.test.tsx
new file mode 100644
index 0000000..9366cc7
--- /dev/null
+++ b/tests/align.test.tsx
@@ -0,0 +1,297 @@
+import { act, cleanup, fireEvent, render } from '@testing-library/react';
+import { spyElementPrototypes } from 'rc-util/lib/test/domHook';
+import React from 'react';
+import type { TriggerProps, TriggerRef } from '../src';
+import Trigger from '../src';
+import { awaitFakeTimer } from './util';
+
+import { _rs } from 'rc-resize-observer';
+
+export const triggerResize = (target: Element) => {
+ act(() => {
+ _rs([{ target } as ResizeObserverEntry]);
+ });
+};
+
+describe('Trigger.Align', () => {
+ let targetVisible = true;
+
+ let rectX = 100;
+ let rectY = 100;
+ let rectWidth = 100;
+ let rectHeight = 100;
+
+ beforeAll(() => {
+ spyElementPrototypes(HTMLDivElement, {
+ getBoundingClientRect: () => ({
+ x: rectX,
+ y: rectY,
+ width: rectWidth,
+ height: rectHeight,
+ right: 200,
+ bottom: 200,
+ }),
+ });
+
+ spyElementPrototypes(HTMLElement, {
+ offsetParent: {
+ get: () => (targetVisible ? document.body : null),
+ },
+ });
+ spyElementPrototypes(SVGElement, {
+ offsetParent: {
+ get: () => (targetVisible ? document.body : null),
+ },
+ });
+ });
+
+ beforeEach(() => {
+ targetVisible = true;
+
+ rectX = 100;
+ rectY = 100;
+ rectWidth = 100;
+ rectHeight = 100;
+
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ cleanup();
+ jest.useRealTimers();
+ });
+
+ it('not show', async () => {
+ const onAlign = jest.fn();
+
+ const Demo = (props: Partial) => {
+ const scrollRef = React.useRef(null);
+
+ return (
+ <>
+
+ trigger}
+ getPopupContainer={() => scrollRef.current!}
+ {...props}
+ >
+
+
+ >
+ );
+ };
+
+ const { rerender, container } = render();
+ const scrollDiv = container.querySelector('.scroll')!;
+
+ const mockAddEvent = jest.spyOn(scrollDiv, 'addEventListener');
+
+ expect(mockAddEvent).not.toHaveBeenCalled();
+
+ // Visible
+ rerender();
+ expect(mockAddEvent).toHaveBeenCalled();
+
+ // Scroll
+ onAlign.mockReset();
+ fireEvent.scroll(scrollDiv);
+
+ await awaitFakeTimer();
+ expect(onAlign).toHaveBeenCalled();
+ });
+
+ it('resize align', async () => {
+ const onAlign = jest.fn();
+
+ const { container } = render(
+ trigger}
+ >
+
+ ,
+ );
+
+ await Promise.resolve();
+ onAlign.mockReset();
+
+ // Resize
+ const target = container.querySelector('.target')!;
+ triggerResize(target);
+
+ await awaitFakeTimer();
+ expect(onAlign).toHaveBeenCalled();
+ });
+
+ it('placement is higher than popupAlign', async () => {
+ render(
+ }
+ builtinPlacements={{
+ top: {},
+ }}
+ popupPlacement="top"
+ popupAlign={{}}
+ >
+
+ ,
+ );
+
+ await awaitFakeTimer();
+
+ expect(
+ document.querySelector('.rc-trigger-popup-placement-top'),
+ ).toBeTruthy();
+ });
+
+ it('invisible should not align', async () => {
+ const onPopupAlign = jest.fn();
+ const triggerRef = React.createRef();
+
+ render(
+ }
+ popupAlign={{}}
+ onPopupAlign={onPopupAlign}
+ ref={triggerRef}
+ >
+
+ ,
+ );
+
+ await awaitFakeTimer();
+
+ expect(onPopupAlign).toHaveBeenCalled();
+ onPopupAlign.mockReset();
+
+ for (let i = 0; i < 10; i += 1) {
+ triggerRef.current!.forceAlign();
+
+ await awaitFakeTimer();
+ expect(onPopupAlign).toHaveBeenCalled();
+ onPopupAlign.mockReset();
+ }
+
+ // Make invisible
+ targetVisible = false;
+
+ triggerRef.current!.forceAlign();
+ await awaitFakeTimer();
+ expect(onPopupAlign).not.toHaveBeenCalled();
+ });
+
+ it('align should merge into placement', async () => {
+ render(
+ }
+ builtinPlacements={{
+ top: {
+ targetOffset: [0, 0],
+ },
+ }}
+ popupPlacement="top"
+ popupAlign={{
+ targetOffset: [-903, -1128],
+ }}
+ >
+
+ ,
+ );
+
+ await awaitFakeTimer();
+
+ expect(
+ document.querySelector('.rc-trigger-popup-placement-top'),
+ ).toHaveStyle({
+ left: `753px`,
+ top: `978px`,
+ });
+ });
+
+ it('targetOffset support ptg', async () => {
+ render(
+ }
+ popupAlign={{
+ targetOffset: ['50%', '-50%'],
+ }}
+ >
+
+ ,
+ );
+
+ await awaitFakeTimer();
+
+ // Correct this if I miss understand the value calculation
+ // from https://github.com/yiminghe/dom-align/blob/master/src/getElFuturePos.js
+ expect(document.querySelector('.rc-trigger-popup')).toHaveStyle({
+ left: `-50px`,
+ top: `50px`,
+ });
+ });
+
+ it('support dynamicInset', async () => {
+ render(
+ }
+ popupAlign={{
+ points: ['bc', 'tc'],
+ _experimental: {
+ dynamicInset: true,
+ },
+ }}
+ >
+
+ ,
+ );
+
+ await awaitFakeTimer();
+
+ expect(document.querySelector('.rc-trigger-popup')).toHaveStyle({
+ bottom: `100px`,
+ });
+ });
+
+ it('round when decimal precision', async () => {
+ rectX = 22.6;
+ rectY = 33.4;
+ rectWidth = 33.7;
+ rectHeight = 55.9;
+
+ render(
+ }
+ popupAlign={{
+ points: ['tl', 'bl'],
+ }}
+ >
+
+ ,
+ );
+
+ await awaitFakeTimer();
+
+ expect(document.querySelector('.rc-trigger-popup')).toHaveStyle({
+ top: `56px`,
+ });
+ });
+});
diff --git a/tests/arrow.test.jsx b/tests/arrow.test.jsx
new file mode 100644
index 0000000..80be36a
--- /dev/null
+++ b/tests/arrow.test.jsx
@@ -0,0 +1,212 @@
+/* eslint-disable max-classes-per-file */
+
+import { act, cleanup, render } from '@testing-library/react';
+import {
+ spyElementPrototype,
+ spyElementPrototypes,
+} from 'rc-util/lib/test/domHook';
+import Trigger from '../src';
+
+describe('Trigger.Arrow', () => {
+ beforeAll(() => {
+ spyElementPrototypes(HTMLElement, {
+ offsetParent: {
+ get: () => document.body,
+ },
+ });
+ });
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ cleanup();
+ jest.useRealTimers();
+ });
+
+ async function awaitFakeTimer() {
+ for (let i = 0; i < 10; i += 1) {
+ await act(async () => {
+ jest.advanceTimersByTime(100);
+ await Promise.resolve();
+ });
+ }
+ }
+
+ it('not show', () => {
+ render(
+ trigger} arrow>
+
+ ,
+ );
+ });
+
+ describe('direction', () => {
+ let divSpy;
+ let windowSpy;
+
+ beforeAll(() => {
+ divSpy = spyElementPrototype(
+ HTMLDivElement,
+ 'getBoundingClientRect',
+ () => ({
+ x: 200,
+ y: 200,
+ width: 100,
+ height: 50,
+ }),
+ );
+
+ windowSpy = spyElementPrototypes(Window, {
+ clientWidth: {
+ get: () => 1000,
+ },
+ clientHeight: {
+ get: () => 1000,
+ },
+ });
+ });
+
+ afterAll(() => {
+ divSpy.mockRestore();
+ windowSpy.mockRestore();
+ });
+
+ function test(name, align, style) {
+ it(name, async () => {
+ render(
+ trigger}
+ arrow
+ >
+
+ ,
+ );
+
+ await awaitFakeTimer();
+
+ expect(document.querySelector('.rc-trigger-popup-arrow')).toHaveStyle(
+ style,
+ );
+ });
+ }
+
+ // Top
+ test(
+ 'top',
+ {
+ points: ['bl', 'tl'],
+ },
+ {
+ bottom: 0,
+ },
+ );
+
+ // Bottom
+ test(
+ 'bottom',
+ {
+ points: ['tc', 'bc'],
+ },
+ {
+ top: 0,
+ },
+ );
+
+ // Left
+ test(
+ 'left',
+ {
+ points: ['cr', 'cl'],
+ },
+ {
+ right: 0,
+ },
+ );
+
+ // Right
+ test(
+ 'right',
+ {
+ points: ['cl', 'cr'],
+ },
+ {
+ left: 0,
+ },
+ );
+
+ it('not aligned', async () => {
+ render(
+ trigger}
+ arrow
+ >
+
+ ,
+ );
+
+ await awaitFakeTimer();
+
+ // Not have other align style
+ const { style } = document.querySelector('.rc-trigger-popup-arrow');
+ expect(style.position).toBeTruthy();
+ expect(style.left).toBeFalsy();
+ expect(style.right).toBeFalsy();
+ expect(style.top).toBeFalsy();
+ expect(style.bottom).toBeFalsy();
+ });
+
+ it('arrow classname', async () => {
+ render(
+ trigger}
+ arrow={{
+ className: 'abc',
+ }}
+ >
+
+ ,
+ );
+
+ await awaitFakeTimer();
+
+ const arrowDom = document.querySelector('.rc-trigger-popup-arrow');
+ expect(arrowDom.classList.contains('abc')).toBeTruthy();
+ });
+ });
+
+ it('content', async () => {
+ render(
+ trigger}
+ arrow={{
+ content: ,
+ }}
+ >
+
+ ,
+ );
+
+ await awaitFakeTimer();
+
+ expect(document.querySelector('.my-content')).toBeTruthy();
+ });
+});
diff --git a/tests/basic.test.jsx b/tests/basic.test.jsx
new file mode 100644
index 0000000..aa4e87d
--- /dev/null
+++ b/tests/basic.test.jsx
@@ -0,0 +1,1201 @@
+/* eslint-disable max-classes-per-file */
+
+import { act, cleanup, fireEvent, render } from '@testing-library/react';
+import { spyElementPrototypes } from 'rc-util/lib/test/domHook';
+import React, { StrictMode, createRef } from 'react';
+import ReactDOM from 'react-dom';
+import Trigger from '../src';
+import { awaitFakeTimer, placementAlignMap } from './util';
+
+describe('Trigger.Basic', () => {
+ beforeAll(() => {
+ spyElementPrototypes(HTMLElement, {
+ offsetParent: {
+ get: () => document.body,
+ },
+ });
+ });
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ cleanup();
+ jest.useRealTimers();
+ });
+
+ function trigger(dom, selector, method = 'click') {
+ fireEvent[method](dom.querySelector(selector));
+ act(() => jest.runAllTimers());
+ }
+
+ function isPopupHidden() {
+ return document
+ .querySelector('.rc-trigger-popup')
+ .className.includes('-hidden');
+ }
+ function isPopupClassHidden(name) {
+ return document.querySelector(name).className.includes('-hidden');
+ }
+ function isPopupAllHidden() {
+ const popupArr = document.querySelectorAll('.rc-trigger-popup');
+
+ return Array.from(popupArr).every((item) =>
+ item.className.includes('-hidden'),
+ );
+ }
+ describe('getPopupContainer', () => {
+ it('defaults to document.body', () => {
+ const { container } = render(
+ tooltip2}
+ >
+ click
+ ,
+ );
+
+ trigger(container, '.target');
+
+ const popupDomNode = document.querySelector('.rc-trigger-popup');
+ expect(popupDomNode.parentNode.parentNode).toBeInstanceOf(
+ HTMLBodyElement,
+ );
+ });
+
+ it('can change', () => {
+ function getPopupContainer(node) {
+ return node.parentNode;
+ }
+
+ const { container } = render(
+
+
tooltip2}
+ >
+ click
+
+
,
+ document.createElement('div'),
+ );
+
+ trigger(container, '.target');
+
+ const popupDomNode = document.querySelector('.rc-trigger-popup');
+ expect(popupDomNode.parentNode).toBe(container.querySelector('.holder'));
+ });
+ });
+
+ describe('action', () => {
+ it('click works', () => {
+ const { container } = render(
+ tooltip2}
+ >
+ click
+ ,
+ );
+
+ trigger(container, '.target');
+ expect(document.querySelector('.x-content').textContent).toBe('tooltip2');
+
+ trigger(container, '.target');
+ expect(isPopupHidden).toBeTruthy();
+ });
+
+ it('click works with function', () => {
+ const popup = function renderPopup() {
+ return tooltip3;
+ };
+ const { container } = render(
+
+ click
+ ,
+ );
+
+ trigger(container, '.target');
+ expect(document.querySelector('.x-content').textContent).toBe('tooltip3');
+
+ trigger(container, '.target');
+ expect(isPopupHidden()).toBeTruthy();
+ });
+
+ describe('hover works', () => {
+ it('mouse event', () => {
+ const { container } = render(
+ trigger}
+ >
+ click
+ ,
+ );
+
+ trigger(container, '.target', 'mouseEnter');
+ expect(isPopupHidden()).toBeFalsy();
+
+ trigger(container, '.target', 'mouseLeave');
+ expect(isPopupHidden()).toBeTruthy();
+ });
+
+ it('pointer event', () => {
+ const { container } = render(
+ trigger}
+ >
+ click
+ ,
+ );
+
+ trigger(container, '.target', 'pointerEnter');
+ expect(isPopupHidden()).toBeFalsy();
+
+ trigger(container, '.target', 'pointerLeave');
+ expect(isPopupHidden()).toBeTruthy();
+
+ // Enter again but move in popup
+ trigger(container, '.target', 'pointerEnter');
+ expect(isPopupHidden()).toBeFalsy();
+
+ fireEvent.pointerLeave(container.querySelector('.target'));
+ trigger(document, '.rc-trigger-popup', 'pointerEnter');
+ expect(isPopupHidden()).toBeFalsy();
+ });
+ });
+
+ it('contextMenu works', () => {
+ const triggerRef = createRef();
+ const { container } = render(
+ trigger}
+ >
+ contextMenu
+ ,
+ );
+
+ trigger(container, '.target', 'contextMenu');
+ expect(isPopupHidden()).toBeFalsy();
+
+ fireEvent.click(document.querySelector('.target'));
+
+ expect(isPopupHidden()).toBeTruthy();
+ });
+ it('contextMenu all close', () => {
+ const triggerRef1 = createRef();
+ const triggerRef2 = createRef();
+ const { container } = render(
+ <>
+ trigger1}
+ >
+ contextMenu 1
+
+ trigger2}
+ >
+ contextMenu 2
+
+ >,
+ );
+
+ trigger(container, '.target1', 'contextMenu');
+ trigger(container, '.target2', 'contextMenu');
+ expect(isPopupClassHidden('.trigger-popup1')).toBeTruthy();
+ expect(isPopupClassHidden('.trigger-popup2')).toBeFalsy();
+
+ trigger(container, '.target1', 'contextMenu');
+ expect(isPopupClassHidden('.trigger-popup1')).toBeFalsy();
+ expect(isPopupClassHidden('.trigger-popup2')).toBeTruthy();
+
+ fireEvent.mouseDown(document.body);
+ expect(isPopupAllHidden()).toBeTruthy();
+ });
+ describe('afterPopupVisibleChange can be triggered', () => {
+ it('uncontrolled', async () => {
+ let triggered = 0;
+ const { container } = render(
+ {
+ triggered = 1;
+ }}
+ popup={trigger}
+ >
+ click
+ ,
+ );
+
+ trigger(container, '.target');
+
+ await awaitFakeTimer();
+
+ expect(triggered).toBe(1);
+ });
+
+ it('controlled', async () => {
+ let triggered = 0;
+
+ const Demo = () => {
+ const [visible, setVisible] = React.useState(false);
+
+ return (
+ <>
+