├── docs ├── examples │ ├── point.less │ ├── mobile.tsx │ ├── static-scroll.tsx │ ├── case.less │ ├── click-nested.tsx │ ├── shadow.tsx │ ├── point.tsx │ ├── portal.tsx │ ├── large-popup.tsx │ ├── clip.tsx │ ├── visible-fallback.tsx │ ├── nested.tsx │ ├── two-buttons.tsx │ ├── inside.tsx │ ├── container.tsx │ ├── body-overflow.tsx │ ├── case.tsx │ └── simple.tsx ├── demos │ ├── case.md │ ├── clip.md │ ├── point.md │ ├── inside.md │ ├── mobile.md │ ├── nested.md │ ├── portal.md │ ├── shadow.md │ ├── simple.md │ ├── container.md │ ├── large-popup.md │ ├── two-buttons.md │ ├── body-overflow.md │ ├── click-nested.md │ ├── static-scroll.md │ └── visible-fallback.md └── index.md ├── jest.config.js ├── index.js ├── .fatherrc.js ├── .prettierrc ├── .github ├── workflows │ ├── main.yml │ └── codeql.yml ├── dependabot.yml └── FUNDING.yml ├── now.json ├── .editorconfig ├── tests ├── setup.js ├── mask.test.jsx ├── ref.test.tsx ├── rect.test.tsx ├── portal.test.jsx ├── util.tsx ├── perf.test.tsx ├── mobile.test.tsx ├── motion.test.jsx ├── flipShift.test.tsx ├── point.test.jsx ├── shadow.test.tsx ├── arrow.test.jsx ├── align.test.tsx └── flip-visibleFirst.test.tsx ├── tsconfig.json ├── src ├── Popup │ ├── PopupContent.tsx │ ├── Mask.tsx │ ├── Arrow.tsx │ └── index.tsx ├── hooks │ ├── useDelay.ts │ ├── useAction.ts │ ├── useOffsetStyle.ts │ ├── useWatch.ts │ └── useWinClick.ts ├── mock.tsx ├── context.ts ├── UniqueProvider │ ├── useTargetState.ts │ ├── UniqueContainer.tsx │ └── index.tsx ├── interface.ts └── util.ts ├── .gitignore ├── .dumirc.ts ├── .eslintrc.js ├── HISTORY.md ├── assets ├── index │ ├── Mobile.less │ └── Mask.less └── index.less ├── LICENSE ├── package.json └── README.md /docs/examples/point.less: -------------------------------------------------------------------------------- 1 | .point-popup { 2 | pointer-events: none; 3 | } 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFiles: ['./tests/setup.js'], 3 | }; 4 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // export this package's api 2 | import Trigger from './src/'; 3 | export default Trigger; 4 | -------------------------------------------------------------------------------- /docs/demos/case.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Case 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demos/clip.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Clip 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demos/point.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Point 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /.fatherrc.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'father'; 2 | 3 | export default defineConfig({ 4 | plugins: ['@rc-component/father-plugin'], 5 | }); 6 | -------------------------------------------------------------------------------- /docs/demos/inside.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Inside 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demos/mobile.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Mobile 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demos/nested.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Nested 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demos/portal.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Portal 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demos/shadow.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Shadow 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demos/simple.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Simple 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hero: 3 | title: rc-trigger 4 | description: React Trigger Component 5 | --- 6 | 7 | 8 | -------------------------------------------------------------------------------- /docs/demos/container.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Container 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demos/large-popup.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Large Popup 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demos/two-buttons.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Moving Popup 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demos/body-overflow.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Body Overflow 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demos/click-nested.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Click Nested 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demos/static-scroll.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Static Scroll 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "all", 7 | "jsxSingleQuote": false 8 | } 9 | -------------------------------------------------------------------------------- /docs/demos/visible-fallback.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Visible Fallback 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: ✅ test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | uses: react-component/rc-test/.github/workflows/test.yml@main 6 | secrets: inherit 7 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "name": "rc-trigger", 4 | "builds": [ 5 | { 6 | "src": "package.json", 7 | "use": "@now/static-build", 8 | "config": { "distDir": ".doc" } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*.{js,css}] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /tests/setup.js: -------------------------------------------------------------------------------- 1 | // jsdom add motion events to test CSSMotion 2 | window.AnimationEvent = window.AnimationEvent || (() => {}); 3 | window.TransitionEvent = window.TransitionEvent || (() => {}); 4 | global.ResizeObserver = jest.fn(() => { 5 | return { 6 | observe() { }, 7 | unobserve() { }, 8 | disconnect() { }, 9 | }; 10 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "moduleResolution": "node", 5 | "baseUrl": "./", 6 | "jsx": "react", 7 | "declaration": true, 8 | "skipLibCheck": true, 9 | "esModuleInterop": true, 10 | "allowSyntheticDefaultImports": true, 11 | "paths": { 12 | "@/*": ["src/*"], 13 | "@@/*": [".dumi/tmp/*"], 14 | "@rc-component/trigger": ["src/index.tsx"] 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Popup/PopupContent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export interface PopupContentProps { 4 | children?: React.ReactNode; 5 | cache?: boolean; 6 | } 7 | 8 | const PopupContent = React.memo( 9 | ({ children }: PopupContentProps) => children as React.ReactElement, 10 | (_, next) => next.cache, 11 | ); 12 | 13 | if (process.env.NODE_ENV !== 'production') { 14 | PopupContent.displayName = 'PopupContent'; 15 | } 16 | 17 | export default PopupContent; 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .storybook 2 | *.iml 3 | *.log 4 | .idea 5 | .ipr 6 | .iws 7 | *~ 8 | ~* 9 | *.diff 10 | *.patch 11 | *.bak 12 | .DS_Store 13 | Thumbs.db 14 | .project 15 | .*proj 16 | .svn 17 | *.swp 18 | *.swo 19 | *.pyc 20 | *.pyo 21 | node_modules 22 | .cache 23 | *.css 24 | build 25 | lib 26 | es 27 | coverage 28 | yarn.lock 29 | package-lock.json 30 | pnpm-lock.yaml 31 | bun.lockb 32 | .vscode 33 | 34 | # dumi 35 | .umi 36 | .umi-production 37 | .umi-test 38 | .docs 39 | 40 | 41 | # dumi 42 | .dumi/tmp 43 | .dumi/tmp-production 44 | dist/ -------------------------------------------------------------------------------- /.dumirc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'dumi'; 2 | import path from 'path'; 3 | 4 | export default defineConfig({ 5 | alias: { 6 | 'rc-trigger$': path.resolve('src'), 7 | 'rc-trigger/es': path.resolve('src'), 8 | }, 9 | mfsu: false, 10 | favicons: ['https://avatars0.githubusercontent.com/u/9441414?s=200&v=4'], 11 | themeConfig: { 12 | name: 'Trigger', 13 | logo: 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4', 14 | }, 15 | styles: [ 16 | ` 17 | .dumi-default-previewer-demo { 18 | position: relative; 19 | min-height: 300px; 20 | } 21 | `, 22 | ], 23 | }); 24 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [require.resolve('@umijs/fabric/dist/eslint')], 3 | rules: { 4 | 'default-case': 0, 5 | 'import/no-extraneous-dependencies': 0, 6 | 'react-hooks/exhaustive-deps': 0, 7 | 'react/no-find-dom-node': 0, 8 | 'react/no-did-update-set-state': 0, 9 | 'react/no-unused-state': 1, 10 | 'react/sort-comp': 0, 11 | 'jsx-a11y/label-has-for': 0, 12 | 'jsx-a11y/label-has-associated-control': 0, 13 | '@typescript-eslint/consistent-indexed-object-style': 0, 14 | '@typescript-eslint/no-parameter-properties': 0, 15 | '@typescript-eslint/ban-types': 0, 16 | '@typescript-eslint/type-annotation-spacing': 0, 17 | '@typescript-eslint/no-throw-literal': 0, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: daily 7 | time: '21:00' 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: np 11 | versions: 12 | - 7.2.0 13 | - 7.3.0 14 | - 7.4.0 15 | - dependency-name: '@types/react-dom' 16 | versions: 17 | - 17.0.0 18 | - 17.0.1 19 | - 17.0.2 20 | - dependency-name: '@types/react' 21 | versions: 22 | - 17.0.0 23 | - 17.0.1 24 | - 17.0.2 25 | - 17.0.3 26 | - dependency-name: typescript 27 | versions: 28 | - 4.1.3 29 | - 4.1.4 30 | - 4.1.5 31 | -------------------------------------------------------------------------------- /src/hooks/useDelay.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export default function useDelay() { 4 | const delayRef = React.useRef | null>(null); 5 | 6 | const clearDelay = () => { 7 | if (delayRef.current) { 8 | clearTimeout(delayRef.current); 9 | delayRef.current = null; 10 | } 11 | }; 12 | 13 | const delayInvoke = (callback: VoidFunction, delay: number) => { 14 | clearDelay(); 15 | 16 | if (delay === 0) { 17 | callback(); 18 | } else { 19 | delayRef.current = setTimeout(() => { 20 | callback(); 21 | }, delay * 1000); 22 | } 23 | }; 24 | 25 | // Clean up on unmount 26 | React.useEffect(() => { 27 | return () => { 28 | clearDelay(); 29 | }; 30 | }, []); 31 | 32 | return delayInvoke; 33 | } 34 | -------------------------------------------------------------------------------- /src/mock.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { generateTrigger, UniqueProvider } from './index'; 3 | 4 | interface MockPortalProps { 5 | open?: boolean; 6 | autoDestroy?: boolean; 7 | children: React.ReactElement; 8 | getContainer?: () => HTMLElement; 9 | } 10 | 11 | const MockPortal: React.FC = ({ 12 | open, 13 | autoDestroy, 14 | children, 15 | getContainer, 16 | }) => { 17 | const [visible, setVisible] = React.useState(open); 18 | 19 | React.useEffect(() => { 20 | getContainer?.(); 21 | }); 22 | 23 | React.useEffect(() => { 24 | if (open) { 25 | setVisible(true); 26 | } else if (autoDestroy) { 27 | setVisible(false); 28 | } 29 | }, [open, autoDestroy]); 30 | 31 | return visible ? children : null; 32 | }; 33 | 34 | export default generateTrigger(MockPortal); 35 | 36 | export { UniqueProvider }; 37 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ant-design # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: ant-design # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: 'CodeQL' 2 | 3 | on: 4 | push: 5 | branches: ['master'] 6 | pull_request: 7 | branches: ['master'] 8 | schedule: 9 | - cron: '40 12 * * 0' 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [javascript] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: '/language:${{ matrix.language }}' 42 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # History 2 | 3 | --- 4 | 5 | ## 4.1.0 / 2020-05-08 6 | 7 | - upgrade rc-animate to `3.x` 8 | 9 | ## 2.5.0 / 2018-06-05 10 | 11 | - support `alignPoint` 12 | 13 | ## 2.1.0 / 2017-10-16 14 | 15 | - add action `contextMenu` 16 | 17 | ## 2.0.0 / 2017-09-25 18 | 19 | - support React 16 20 | 21 | ## 1.11.0 / 2017-06-07 22 | 23 | - add es 24 | 25 | ## 1.9.0 / 2017-02-27 26 | 27 | - add getDocument prop 28 | 29 | ## 1.8.2 / 2017-02-24 30 | 31 | - change default container to absolute to fix scrollbar change problem 32 | 33 | ## 1.7.0 / 2016-07-18 34 | 35 | - use getContainerRenderMixin from 'rc-util' 36 | 37 | ## 1.6.0 / 2016-05-26 38 | 39 | - support popup as function 40 | 41 | ## 1.5.0 / 2016-05-26 42 | 43 | - add forcePopupAlign method 44 | 45 | ## 1.4.0 / 2016-04-06 46 | 47 | - support onPopupAlign 48 | 49 | ## 1.3.0 / 2016-03-25 50 | 51 | - support mask/maskTransitionName/zIndex 52 | 53 | ## 1.2.0 / 2016-03-01 54 | 55 | - add showAction/hideAction 56 | 57 | ## 1.1.0 / 2016-01-06 58 | 59 | - add root trigger node as parameter of getPopupContainer 60 | -------------------------------------------------------------------------------- /src/Popup/Mask.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx'; 2 | import type { CSSMotionProps } from '@rc-component/motion'; 3 | import CSSMotion from '@rc-component/motion'; 4 | import * as React from 'react'; 5 | 6 | export interface MaskProps { 7 | prefixCls: string; 8 | open?: boolean; 9 | zIndex?: number; 10 | mask?: boolean; 11 | 12 | // Motion 13 | motion?: CSSMotionProps; 14 | 15 | mobile?: boolean; 16 | } 17 | 18 | export default function Mask(props: MaskProps) { 19 | const { 20 | prefixCls, 21 | open, 22 | zIndex, 23 | 24 | mask, 25 | motion, 26 | 27 | mobile, 28 | } = props; 29 | 30 | if (!mask) { 31 | return null; 32 | } 33 | 34 | return ( 35 | 36 | {({ className }) => ( 37 |
45 | )} 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /assets/index/Mobile.less: -------------------------------------------------------------------------------- 1 | .@{triggerPrefixCls} { 2 | &-mobile { 3 | position: fixed; 4 | left: 0; 5 | right: 0; 6 | bottom: 0; 7 | top: auto; 8 | 9 | // Motion 10 | &.raise { 11 | &-enter, 12 | &-appear { 13 | transform: translateY(100%); 14 | 15 | &-active { 16 | transition: all 0.3s; 17 | transform: translateY(0); 18 | } 19 | } 20 | 21 | &-leave { 22 | transform: translateY(0); 23 | 24 | &-active { 25 | transition: all 0.3s; 26 | transform: translateY(100%); 27 | } 28 | } 29 | } 30 | 31 | // Mask 32 | &-mask { 33 | &.fade { 34 | &-enter, 35 | &-appear { 36 | opacity: 0; 37 | 38 | &-active { 39 | transition: all 0.3s; 40 | opacity: 1; 41 | } 42 | } 43 | 44 | &-leave { 45 | opacity: 1; 46 | 47 | &-active { 48 | transition: all 0.3s; 49 | opacity: 0; 50 | } 51 | } 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/mask.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { fireEvent, render } from '@testing-library/react'; 3 | import Trigger from '../src'; 4 | import CSSMotion from '@rc-component/motion'; 5 | import { placementAlignMap } from './util'; 6 | 7 | describe('Trigger.Mask', () => { 8 | beforeEach(() => { 9 | jest.useFakeTimers(); 10 | }); 11 | 12 | afterEach(() => { 13 | jest.useRealTimers(); 14 | }); 15 | 16 | it('mask should support motion', () => { 17 | const cssMotionSpy = jest.spyOn(CSSMotion, 'render'); 18 | const { container } = render( 19 | } 23 | mask 24 | maskMotion={{ 25 | motionName: 'bamboo', 26 | }} 27 | > 28 |
click
29 |
, 30 | ); 31 | 32 | const target = container.querySelector('.target'); 33 | fireEvent.click(target); 34 | 35 | expect(cssMotionSpy).toHaveBeenCalledWith( 36 | expect.objectContaining({ motionName: 'bamboo' }), 37 | null, 38 | ); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2015-present Alipay.com, https://www.alipay.com/ 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 15 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/hooks/useAction.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { ActionType } from '../interface'; 3 | 4 | type InternalActionType = ActionType | 'touch'; 5 | 6 | type ActionTypes = InternalActionType | InternalActionType[]; 7 | 8 | function toArray(val?: T | T[]) { 9 | return val ? (Array.isArray(val) ? val : [val]) : []; 10 | } 11 | 12 | export default function useAction( 13 | action: ActionTypes, 14 | showAction?: ActionTypes, 15 | hideAction?: ActionTypes, 16 | ): [showAction: Set, hideAction: Set] { 17 | return React.useMemo(() => { 18 | const mergedShowAction = toArray(showAction ?? action); 19 | const mergedHideAction = toArray(hideAction ?? action); 20 | 21 | const showActionSet = new Set(mergedShowAction); 22 | const hideActionSet = new Set(mergedHideAction); 23 | 24 | if (showActionSet.has('hover') && !showActionSet.has('click')) { 25 | showActionSet.add('touch'); 26 | } 27 | 28 | if (hideActionSet.has('hover') && !hideActionSet.has('click')) { 29 | hideActionSet.add('touch'); 30 | } 31 | 32 | return [showActionSet, hideActionSet]; 33 | }, [action, showAction, hideAction]); 34 | } 35 | -------------------------------------------------------------------------------- /assets/index/Mask.less: -------------------------------------------------------------------------------- 1 | .@{triggerPrefixCls} { 2 | &-mask { 3 | position: fixed; 4 | top: 0; 5 | right: 0; 6 | left: 0; 7 | bottom: 0; 8 | background-color: rgb(55, 55, 55); 9 | background-color: rgba(55, 55, 55, 0.6); 10 | height: 100%; 11 | filter: alpha(opacity=50); 12 | z-index: 1050; 13 | 14 | &-hidden { 15 | display: none; 16 | } 17 | } 18 | 19 | .fade-effect() { 20 | animation-duration: 0.3s; 21 | animation-fill-mode: both; 22 | animation-timing-function: cubic-bezier(0.55, 0, 0.55, 0.2); 23 | } 24 | 25 | &-fade-enter, 26 | &-fade-appear { 27 | opacity: 0; 28 | .fade-effect(); 29 | animation-play-state: paused; 30 | } 31 | 32 | &-fade-leave { 33 | .fade-effect(); 34 | animation-play-state: paused; 35 | } 36 | 37 | &-fade-enter&-fade-enter-active, 38 | &-fade-appear&-fade-appear-active { 39 | animation-name: rcTriggerMaskFadeIn; 40 | animation-play-state: running; 41 | } 42 | 43 | &-fade-leave&-fade-leave-active { 44 | animation-name: rcDialogFadeOut; 45 | animation-play-state: running; 46 | } 47 | 48 | @keyframes rcTriggerMaskFadeIn { 49 | 0% { 50 | opacity: 0; 51 | } 52 | 100% { 53 | opacity: 1; 54 | } 55 | } 56 | 57 | @keyframes rcDialogFadeOut { 58 | 0% { 59 | opacity: 1; 60 | } 61 | 100% { 62 | opacity: 0; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/hooks/useOffsetStyle.ts: -------------------------------------------------------------------------------- 1 | import type * as React from 'react'; 2 | import type { AlignType } from '../interface'; 3 | 4 | export default function useOffsetStyle( 5 | isMobile: boolean, 6 | ready: boolean, 7 | open: boolean, 8 | align: AlignType, 9 | offsetR: number, 10 | offsetB: number, 11 | offsetX: number, 12 | offsetY: number, 13 | ) { 14 | // >>>>> Offset 15 | const AUTO = 'auto' as const; 16 | 17 | const offsetStyle: React.CSSProperties = isMobile 18 | ? {} 19 | : { 20 | left: '-1000vw', 21 | top: '-1000vh', 22 | right: AUTO, 23 | bottom: AUTO, 24 | }; 25 | 26 | // Set align style 27 | if (!isMobile && (ready || !open)) { 28 | const { points } = align; 29 | const dynamicInset = 30 | align.dynamicInset || (align as any)._experimental?.dynamicInset; 31 | const alignRight = dynamicInset && points[0][1] === 'r'; 32 | const alignBottom = dynamicInset && points[0][0] === 'b'; 33 | 34 | if (alignRight) { 35 | offsetStyle.right = offsetR; 36 | offsetStyle.left = AUTO; 37 | } else { 38 | offsetStyle.left = offsetX; 39 | offsetStyle.right = AUTO; 40 | } 41 | 42 | if (alignBottom) { 43 | offsetStyle.bottom = offsetB; 44 | offsetStyle.top = AUTO; 45 | } else { 46 | offsetStyle.top = offsetY; 47 | offsetStyle.bottom = AUTO; 48 | } 49 | } 50 | 51 | return offsetStyle; 52 | } 53 | -------------------------------------------------------------------------------- /tests/ref.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | 3 | import { cleanup, render } from '@testing-library/react'; 4 | import { spyElementPrototypes } from '@rc-component/util/lib/test/domHook'; 5 | import React from 'react'; 6 | import Trigger, { type TriggerRef } from '../src'; 7 | 8 | describe('Trigger.Ref', () => { 9 | beforeAll(() => { 10 | spyElementPrototypes(HTMLElement, { 11 | offsetParent: { 12 | get: () => document.body, 13 | }, 14 | }); 15 | }); 16 | 17 | beforeEach(() => { 18 | jest.useFakeTimers(); 19 | }); 20 | 21 | afterEach(() => { 22 | cleanup(); 23 | jest.useRealTimers(); 24 | }); 25 | 26 | it('support nativeElement', () => { 27 | const triggerRef = React.createRef(); 28 | 29 | const { container } = render( 30 | }> 31 |
65 | ); 66 | }; 67 | 68 | export default Test; 69 | -------------------------------------------------------------------------------- /src/Popup/Arrow.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx'; 2 | import * as React from 'react'; 3 | import type { AlignType, ArrowPos, ArrowTypeOuter } from '../interface'; 4 | 5 | export interface ArrowProps { 6 | prefixCls: string; 7 | align: AlignType; 8 | arrow: ArrowTypeOuter; 9 | arrowPos: ArrowPos; 10 | } 11 | 12 | export default function Arrow(props: ArrowProps) { 13 | const { prefixCls, align, arrow, arrowPos } = props; 14 | 15 | const { className, content, style } = arrow || {}; 16 | const { x = 0, y = 0 } = arrowPos; 17 | 18 | const arrowRef = React.useRef(null); 19 | 20 | // Skip if no align 21 | if (!align || !align.points) { 22 | return null; 23 | } 24 | 25 | const alignStyle: React.CSSProperties = { 26 | position: 'absolute', 27 | }; 28 | 29 | // Skip if no need to align 30 | if (align.autoArrow !== false) { 31 | const popupPoints = align.points[0]; 32 | const targetPoints = align.points[1]; 33 | const popupTB = popupPoints[0]; 34 | const popupLR = popupPoints[1]; 35 | const targetTB = targetPoints[0]; 36 | const targetLR = targetPoints[1]; 37 | 38 | // Top & Bottom 39 | if (popupTB === targetTB || !['t', 'b'].includes(popupTB)) { 40 | alignStyle.top = y; 41 | } else if (popupTB === 't') { 42 | alignStyle.top = 0; 43 | } else { 44 | alignStyle.bottom = 0; 45 | } 46 | 47 | // Left & Right 48 | if (popupLR === targetLR || !['l', 'r'].includes(popupLR)) { 49 | alignStyle.left = x; 50 | } else if (popupLR === 'l') { 51 | alignStyle.left = 0; 52 | } else { 53 | alignStyle.right = 0; 54 | } 55 | } 56 | 57 | return ( 58 |
63 | {content} 64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /docs/examples/static-scroll.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import Trigger from '@rc-component/trigger'; 3 | import React from 'react'; 4 | import '../../assets/index.less'; 5 | import { builtinPlacements } from './inside'; 6 | 7 | export default () => { 8 | return ( 9 | 10 |
20 | 32 | Popup 33 |
34 | } 35 | popupStyle={{ boxShadow: '0 0 5px red' }} 36 | popupVisible 37 | builtinPlacements={builtinPlacements} 38 | popupPlacement="top" 39 | stretch="minWidth" 40 | getPopupContainer={(e) => e.parentElement!} 41 | > 42 | 53 | Target 54 | 55 | 56 | {new Array(20).fill(null).map((_, index) => ( 57 |

58 | Placeholder Line {index} 59 |

60 | ))} 61 | 62 |
63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /tests/rect.test.tsx: -------------------------------------------------------------------------------- 1 | import { cleanup, render } from '@testing-library/react'; 2 | import { spyElementPrototypes } from '@rc-component/util/lib/test/domHook'; 3 | import React from 'react'; 4 | import Trigger from '../src'; 5 | import { awaitFakeTimer } from './util'; 6 | 7 | describe('Trigger.Rect', () => { 8 | let targetVisible = true; 9 | 10 | let rectX = 100; 11 | let rectY = 100; 12 | let rectWidth = 100; 13 | let rectHeight = 100; 14 | 15 | beforeAll(() => { 16 | spyElementPrototypes(HTMLDivElement, { 17 | getBoundingClientRect: () => ({ 18 | left: rectX, 19 | top: rectY, 20 | width: rectWidth, 21 | height: rectHeight, 22 | right: 200, 23 | bottom: 200, 24 | }), 25 | }); 26 | 27 | spyElementPrototypes(HTMLElement, { 28 | offsetParent: { 29 | get: () => (targetVisible ? document.body : null), 30 | }, 31 | }); 32 | spyElementPrototypes(SVGElement, { 33 | offsetParent: { 34 | get: () => (targetVisible ? document.body : null), 35 | }, 36 | }); 37 | }); 38 | 39 | beforeEach(() => { 40 | targetVisible = true; 41 | 42 | rectX = 100; 43 | rectY = 100; 44 | rectWidth = 100; 45 | rectHeight = 100; 46 | 47 | jest.useFakeTimers(); 48 | }); 49 | 50 | afterEach(() => { 51 | cleanup(); 52 | jest.useRealTimers(); 53 | }); 54 | 55 | it('getBoundingClientRect top and left', async () => { 56 | render( 57 | } 60 | popupAlign={{ 61 | points: ['bc', 'tc'], 62 | _experimental: { 63 | dynamicInset: true, 64 | }, 65 | }} 66 | > 67 |
68 | , 69 | ); 70 | 71 | await awaitFakeTimer(); 72 | 73 | expect(document.querySelector('.rc-trigger-popup')).toHaveStyle({ 74 | bottom: `100px`, 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /tests/portal.test.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | 3 | import { act, cleanup, fireEvent, render } from '@testing-library/react'; 4 | import { spyElementPrototypes } from '@rc-component/util/lib/test/domHook'; 5 | import React from 'react'; 6 | import ReactDOM from 'react-dom'; 7 | import Trigger from '../src'; 8 | import { placementAlignMap } from './util'; 9 | 10 | describe('Trigger.Portal', () => { 11 | beforeAll(() => { 12 | spyElementPrototypes(HTMLElement, { 13 | offsetParent: { 14 | get: () => document.body, 15 | }, 16 | }); 17 | }); 18 | 19 | beforeEach(() => { 20 | jest.useFakeTimers(); 21 | }); 22 | 23 | afterEach(() => { 24 | cleanup(); 25 | jest.useRealTimers(); 26 | }); 27 | 28 | it('no trigger with portal element', () => { 29 | const PortalBox = () => { 30 | return ReactDOM.createPortal( 31 |
, 32 | document.body, 33 | ); 34 | }; 35 | 36 | const onOpenChange = jest.fn(); 37 | 38 | const { container } = render( 39 |
40 | 46 | tooltip2 47 | 48 | 49 | } 50 | > 51 |
hover
52 |
53 |
, 54 | ); 55 | 56 | // Show the popup 57 | fireEvent.mouseEnter(container.querySelector('.target')); 58 | expect(onOpenChange).toHaveBeenCalledWith(true); 59 | fireEvent.mouseLeave(container.querySelector('.target')); 60 | 61 | // Mouse enter popup 62 | fireEvent.mouseEnter(document.querySelector('.x-content')); 63 | fireEvent.mouseLeave(document.querySelector('.x-content')); 64 | 65 | // To Portal 66 | fireEvent.mouseEnter(document.querySelector('.portal-box')); 67 | act(() => { 68 | jest.runAllTimers(); 69 | }); 70 | 71 | expect(onOpenChange).toHaveBeenCalledWith(false); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /docs/examples/case.less: -------------------------------------------------------------------------------- 1 | // .rc-trigger-popup-placement-right { 2 | // border-width: 10px!important; 3 | // } 4 | 5 | // ======================= Popup ======================= 6 | .case-motion { 7 | transform-origin: 50% 50%; 8 | 9 | animation-duration: 0.3s; 10 | animation-timing-function: cubic-bezier(0.18, 0.89, 0.32, 1.28); 11 | animation-fill-mode: both; 12 | 13 | &::after { 14 | content: 'Animating...'; 15 | position: absolute; 16 | bottom: -3em; 17 | } 18 | 19 | &-appear, 20 | &-enter { 21 | animation-play-state: paused; 22 | 23 | &-active { 24 | animation-name: case-zoom-in; 25 | animation-play-state: running; 26 | } 27 | } 28 | 29 | &-leave { 30 | animation-play-state: paused; 31 | 32 | &-active { 33 | animation-name: case-zoom-out; 34 | animation-play-state: running; 35 | } 36 | } 37 | } 38 | 39 | @keyframes case-zoom-in { 40 | 0% { 41 | opacity: 0; 42 | transform: scale(0); 43 | } 44 | 100% { 45 | opacity: 1; 46 | transform: scale(1); 47 | } 48 | } 49 | 50 | @keyframes case-zoom-out { 51 | 0% { 52 | opacity: 1; 53 | transform: scale(1); 54 | } 55 | 100% { 56 | opacity: 0; 57 | transform: scale(1.2); 58 | } 59 | } 60 | 61 | // ======================= Mask ======================= 62 | .mask-motion { 63 | animation-duration: 0.3s; 64 | animation-fill-mode: both; 65 | position: fixed; 66 | left: 0; 67 | right: 0; 68 | top: 0; 69 | bottom: 0; 70 | background: rgba(0, 0, 0, 0.3); 71 | 72 | &-appear, 73 | &-enter { 74 | animation-play-state: paused; 75 | opacity: 0; 76 | 77 | &-active { 78 | animation-name: mask-zoom-in; 79 | animation-play-state: running; 80 | } 81 | } 82 | 83 | &-leave { 84 | animation-play-state: paused; 85 | 86 | &-active { 87 | animation-name: mask-zoom-out; 88 | animation-play-state: running; 89 | } 90 | } 91 | } 92 | 93 | @keyframes mask-zoom-in { 94 | 0% { 95 | opacity: 0; 96 | } 97 | 100% { 98 | opacity: 1; 99 | } 100 | } 101 | 102 | @keyframes mask-zoom-out { 103 | 0% { 104 | opacity: 1; 105 | } 106 | 100% { 107 | opacity: 0; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rc-component/trigger", 3 | "version": "3.7.1", 4 | "description": "base abstract trigger component for react", 5 | "engines": { 6 | "node": ">=8.x" 7 | }, 8 | "keywords": [ 9 | "react", 10 | "react-component", 11 | "react-trigger", 12 | "trigger" 13 | ], 14 | "homepage": "https://github.com/react-component/trigger", 15 | "author": "", 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/react-component/trigger.git" 19 | }, 20 | "bugs": { 21 | "url": "https://github.com/react-component/trigger/issues" 22 | }, 23 | "files": [ 24 | "es", 25 | "lib", 26 | "assets/**/*.css", 27 | "assets/**/*.less" 28 | ], 29 | "license": "MIT", 30 | "main": "./lib/index", 31 | "module": "./es/index", 32 | "scripts": { 33 | "start": "dumi dev", 34 | "build": "dumi build", 35 | "compile": "father build && lessc assets/index.less assets/index.css", 36 | "prepublishOnly": "npm run compile && rc-np", 37 | "lint": "eslint src/ docs/examples/ --ext .tsx,.ts,.jsx,.js", 38 | "test": "rc-test", 39 | "prettier": "prettier --write .", 40 | "coverage": "rc-test --coverage", 41 | "now-build": "npm run build" 42 | }, 43 | "dependencies": { 44 | "@rc-component/motion": "^1.1.4", 45 | "@rc-component/portal": "^2.0.0", 46 | "@rc-component/resize-observer": "^1.0.0", 47 | "@rc-component/util": "^1.2.1", 48 | "clsx": "^2.1.1" 49 | }, 50 | "devDependencies": { 51 | "@rc-component/father-plugin": "^2.0.0", 52 | "@rc-component/np": "^1.0.3", 53 | "@testing-library/jest-dom": "^6.1.4", 54 | "@testing-library/react": "^16.0.0", 55 | "@types/jest": "^29.5.2", 56 | "@types/node": "^24.0.3", 57 | "@types/react": "^19.1.2", 58 | "@types/react-dom": "^19.1.2", 59 | "@umijs/fabric": "^4.0.1", 60 | "cross-env": "^7.0.1", 61 | "dumi": "^2.1.0", 62 | "eslint": "^8.51.0", 63 | "father": "^4.0.0", 64 | "less": "^4.2.0", 65 | "prettier": "^3.3.3", 66 | "rc-test": "^7.0.13", 67 | "react": "^19.1.0", 68 | "react-dom": "^19.1.0", 69 | "regenerator-runtime": "^0.14.0", 70 | "typescript": "~5.1.6" 71 | }, 72 | "peerDependencies": { 73 | "react": ">=18.0.0", 74 | "react-dom": ">=18.0.0" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /docs/examples/click-nested.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | 3 | import Trigger from '@rc-component/trigger'; 4 | import React from 'react'; 5 | import '../../assets/index.less'; 6 | 7 | const builtinPlacements = { 8 | left: { 9 | points: ['cr', 'cl'], 10 | }, 11 | right: { 12 | points: ['cl', 'cr'], 13 | }, 14 | top: { 15 | points: ['bc', 'tc'], 16 | }, 17 | bottom: { 18 | points: ['tc', 'bc'], 19 | }, 20 | topLeft: { 21 | points: ['bl', 'tl'], 22 | }, 23 | topRight: { 24 | points: ['br', 'tr'], 25 | }, 26 | bottomRight: { 27 | points: ['tr', 'br'], 28 | }, 29 | bottomLeft: { 30 | points: ['tl', 'bl'], 31 | }, 32 | }; 33 | 34 | const popupBorderStyle = { 35 | border: '1px solid red', 36 | padding: 10, 37 | background: 'rgba(255, 0, 0, 0.1)', 38 | }; 39 | 40 | const NestPopup = ({ open, setOpen }) => { 41 | return ( 42 | i am a click popup
} 47 | popupVisible={open} 48 | onOpenChange={setOpen} 49 | > 50 |
51 | i am a click popup{' '} 52 | 61 |
62 | 63 | ); 64 | }; 65 | 66 | NestPopup.displayName = '🐞 NestPopup'; 67 | 68 | const Test = () => { 69 | const [open1, setOpen1] = React.useState(false); 70 | const [open2, setOpen2] = React.useState(false); 71 | 72 | return ( 73 |
74 |
75 | 84 | } 85 | fresh 86 | > 87 | Click Me 88 | 89 |
90 |
91 | ); 92 | }; 93 | 94 | export default Test; 95 | -------------------------------------------------------------------------------- /docs/examples/shadow.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import Trigger from '@rc-component/trigger'; 3 | import React from 'react'; 4 | import { createRoot } from 'react-dom/client'; 5 | import '../../assets/index.less'; 6 | 7 | const Demo = () => { 8 | return ( 9 | 10 | 24 | Popup 25 |
26 | } 27 | popupStyle={{ boxShadow: '0 0 5px red', position: 'absolute' }} 28 | getPopupContainer={(item) => item.parentElement!} 29 | popupAlign={{ 30 | points: ['bc', 'tc'], 31 | overflow: { 32 | shiftX: 50, 33 | adjustY: true, 34 | }, 35 | offset: [0, -10], 36 | }} 37 | stretch="minWidth" 38 | autoDestroy 39 | > 40 | 52 | Target 53 | 54 |
55 | 56 | ); 57 | }; 58 | 59 | export default () => { 60 | React.useEffect(() => { 61 | const wrapperHost = document.createElement('div'); 62 | const wrapperShadowRoot = wrapperHost.attachShadow({ 63 | mode: 'open', 64 | delegatesFocus: false, 65 | }); 66 | document.body.appendChild(wrapperHost); 67 | 68 | const host = document.createElement('div'); 69 | wrapperShadowRoot.appendChild(host); 70 | host.style.background = 'rgba(255,0,0,0.1)'; 71 | const shadowRoot = host.attachShadow({ 72 | mode: 'open', 73 | delegatesFocus: false, 74 | }); 75 | 76 | const container = document.createElement('div'); 77 | shadowRoot.appendChild(container); 78 | 79 | createRoot(container).render(); 80 | }, []); 81 | 82 | return null; 83 | }; 84 | -------------------------------------------------------------------------------- /src/UniqueProvider/useTargetState.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useEvent } from '@rc-component/util'; 3 | import type { UniqueShowOptions } from '../context'; 4 | 5 | /** 6 | * Control the state of popup bind target: 7 | * 1. When set `target`. Do show the popup. 8 | * 2. When `target` is removed. Do hide the popup. 9 | * 3. When `target` change to another one: 10 | * a. We wait motion finish of previous popup. 11 | * b. Then we set new target and show the popup. 12 | * 4. During appear/enter animation, cache new options and apply after animation completes. 13 | */ 14 | export default function useTargetState(): [ 15 | trigger: (options: UniqueShowOptions | false) => void, 16 | open: boolean, 17 | /* Will always cache last which is not null */ 18 | cacheOptions: UniqueShowOptions | null, 19 | onVisibleChanged: (visible: boolean) => void, 20 | ] { 21 | const [options, setOptions] = React.useState(null); 22 | const [open, setOpen] = React.useState(false); 23 | const [isAnimating, setIsAnimating] = React.useState(false); 24 | const pendingOptionsRef = React.useRef(null); 25 | 26 | const trigger = useEvent((nextOptions: UniqueShowOptions | false) => { 27 | if (nextOptions === false) { 28 | // Clear pending options when hiding 29 | pendingOptionsRef.current = null; 30 | setOpen(false); 31 | } else { 32 | if (isAnimating && open) { 33 | // If animating (appear or enter), cache new options 34 | pendingOptionsRef.current = nextOptions; 35 | } else { 36 | setOpen(true); 37 | // Set new options 38 | setOptions(nextOptions); 39 | pendingOptionsRef.current = null; 40 | 41 | // Only mark as animating when transitioning from closed to open 42 | if (!open) { 43 | setIsAnimating(true); 44 | } 45 | } 46 | } 47 | }); 48 | 49 | const onVisibleChanged = useEvent((visible: boolean) => { 50 | if (visible) { 51 | // Animation enter completed, check if there are pending options 52 | setIsAnimating(false); 53 | if (pendingOptionsRef.current) { 54 | // Apply pending options 55 | setOptions(pendingOptionsRef.current); 56 | pendingOptionsRef.current = null; 57 | } 58 | } else { 59 | // Animation leave completed 60 | setIsAnimating(false); 61 | pendingOptionsRef.current = null; 62 | } 63 | }); 64 | 65 | return [trigger, open, options, onVisibleChanged]; 66 | } 67 | -------------------------------------------------------------------------------- /docs/examples/point.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | 3 | import React from 'react'; 4 | import type { ActionType } from '@rc-component/trigger'; 5 | import Trigger from '@rc-component/trigger'; 6 | import '../../assets/index.less'; 7 | import './point.less'; 8 | 9 | const builtinPlacements = { 10 | topLeft: { 11 | points: ['tl', 'tl'], 12 | }, 13 | }; 14 | 15 | const innerTrigger = ( 16 |
17 | This is popup 18 |
19 | ); 20 | 21 | class Test extends React.Component { 22 | state = { 23 | action: 'click' as ActionType, 24 | mouseEnterDelay: 0, 25 | }; 26 | 27 | onActionChange = ({ target: { value } }) => { 28 | this.setState({ action: value }); 29 | }; 30 | 31 | onDelayChange = ({ target: { value } }) => { 32 | this.setState({ mouseEnterDelay: Number(value) || 0 }); 33 | }; 34 | 35 | render() { 36 | const { action, mouseEnterDelay } = this.state; 37 | 38 | return ( 39 |
40 | {' '} 48 | {action === 'hover' && ( 49 | 57 | )} 58 |
59 | 74 |
81 | Interactive region 82 |
83 |
84 |
85 |
86 | ); 87 | } 88 | } 89 | 90 | export default Test; 91 | -------------------------------------------------------------------------------- /tests/util.tsx: -------------------------------------------------------------------------------- 1 | import { act } from '@testing-library/react'; 2 | 3 | const autoAdjustOverflow = { 4 | adjustX: 1, 5 | adjustY: 1, 6 | }; 7 | 8 | const targetOffsetG = [0, 0]; 9 | 10 | export const placementAlignMap = { 11 | left: { 12 | points: ['cr', 'cl'], 13 | overflow: autoAdjustOverflow, 14 | offset: [-3, 0], 15 | targetOffsetG, 16 | }, 17 | right: { 18 | points: ['cl', 'cr'], 19 | overflow: autoAdjustOverflow, 20 | offset: [3, 0], 21 | targetOffsetG, 22 | }, 23 | top: { 24 | points: ['bc', 'tc'], 25 | overflow: autoAdjustOverflow, 26 | offset: [0, -3], 27 | targetOffsetG, 28 | }, 29 | bottom: { 30 | points: ['tc', 'bc'], 31 | overflow: autoAdjustOverflow, 32 | offset: [0, 3], 33 | targetOffsetG, 34 | }, 35 | topLeft: { 36 | points: ['bl', 'tl'], 37 | overflow: autoAdjustOverflow, 38 | offset: [0, -3], 39 | targetOffsetG, 40 | }, 41 | topRight: { 42 | points: ['br', 'tr'], 43 | overflow: autoAdjustOverflow, 44 | offset: [0, -3], 45 | targetOffsetG, 46 | }, 47 | bottomRight: { 48 | points: ['tr', 'br'], 49 | overflow: autoAdjustOverflow, 50 | offset: [0, 3], 51 | targetOffsetG, 52 | }, 53 | bottomLeft: { 54 | points: ['tl', 'bl'], 55 | overflow: autoAdjustOverflow, 56 | offset: [0, 3], 57 | targetOffsetG, 58 | }, 59 | }; 60 | 61 | // https://github.com/testing-library/react-testing-library/issues/268 62 | export class FakeMouseEvent extends MouseEvent { 63 | constructor(type, values) { 64 | const { 65 | pageX, 66 | pageY, 67 | offsetX, 68 | offsetY, 69 | x, 70 | y, 71 | preventDefault, 72 | ...mouseValues 73 | } = values; 74 | super(type, mouseValues); 75 | 76 | Object.assign(this, { 77 | offsetX: offsetX || 0, 78 | offsetY: offsetY || 0, 79 | pageX: pageX || 0, 80 | pageY: pageY || 0, 81 | x: x || 0, 82 | y: y || 0, 83 | ...(preventDefault ? { preventDefault } : {}), 84 | }); 85 | } 86 | } 87 | 88 | export function getMouseEvent(type: string, values = {}): FakeMouseEvent { 89 | values = { 90 | bubbles: true, 91 | cancelable: true, 92 | ...values, 93 | }; 94 | return new FakeMouseEvent(type, values); 95 | } 96 | 97 | export async function awaitFakeTimer() { 98 | for (let i = 0; i < 10; i += 1) { 99 | await act(async () => { 100 | jest.advanceTimersByTime(100); 101 | await Promise.resolve(); 102 | }); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /docs/examples/portal.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | 3 | import Trigger from '@rc-component/trigger'; 4 | import React from 'react'; 5 | import { createPortal } from 'react-dom'; 6 | import '../../assets/index.less'; 7 | 8 | const builtinPlacements = { 9 | left: { 10 | points: ['cr', 'cl'], 11 | }, 12 | right: { 13 | points: ['cl', 'cr'], 14 | }, 15 | top: { 16 | points: ['bc', 'tc'], 17 | }, 18 | bottom: { 19 | points: ['tc', 'bc'], 20 | }, 21 | topLeft: { 22 | points: ['bl', 'tl'], 23 | }, 24 | topRight: { 25 | points: ['br', 'tr'], 26 | }, 27 | bottomRight: { 28 | points: ['tr', 'br'], 29 | }, 30 | bottomLeft: { 31 | points: ['tl', 'bl'], 32 | }, 33 | }; 34 | 35 | const popupBorderStyle = { 36 | border: '1px solid red', 37 | padding: 10, 38 | background: 'rgba(255, 0, 0, 0.1)', 39 | }; 40 | 41 | const PortalPopup = () => 42 | createPortal( 43 |
{ 46 | console.log('Portal Down', e); 47 | e.stopPropagation(); 48 | e.preventDefault(); 49 | }} 50 | > 51 | i am a portal element 52 |
, 53 | document.body, 54 | ); 55 | 56 | const Test = () => { 57 | const buttonRef = React.useRef(null); 58 | React.useEffect(() => { 59 | const button = buttonRef.current; 60 | if (button) { 61 | button.addEventListener('mousedown', (e) => { 62 | console.log('button natives down'); 63 | e.stopPropagation(); 64 | e.preventDefault(); 65 | }); 66 | } 67 | }, []); 68 | 69 | return ( 70 |
79 | 85 | i am a click popup 86 | 87 |
88 | } 89 | onOpenChange={(visible) => { 90 | console.log('visible change:', visible); 91 | }} 92 | > 93 | 94 | 95 | 96 | 105 | 106 | 107 | ); 108 | }; 109 | 110 | export default Test; 111 | -------------------------------------------------------------------------------- /docs/examples/large-popup.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import Trigger from '@rc-component/trigger'; 3 | import React from 'react'; 4 | import '../../assets/index.less'; 5 | 6 | const builtinPlacements = { 7 | top: { 8 | points: ['bc', 'tc'], 9 | overflow: { 10 | shiftY: true, 11 | adjustY: true, 12 | }, 13 | offset: [0, -10], 14 | }, 15 | bottom: { 16 | points: ['tc', 'bc'], 17 | overflow: { 18 | shiftY: true, 19 | adjustY: true, 20 | }, 21 | offset: [0, 10], 22 | htmlRegion: 'scroll' as const, 23 | }, 24 | }; 25 | 26 | export default () => { 27 | const containerRef = React.useRef(null); 28 | 29 | React.useEffect(() => { 30 | console.clear(); 31 | containerRef.current.scrollTop = document.defaultView.innerHeight * 0.75; 32 | }, []); 33 | 34 | return ( 35 | 36 |
40 |
51 |
60 | 74 | Popup 75vh 75 |
76 | } 77 | popupStyle={{ boxShadow: '0 0 5px red' }} 78 | popupVisible 79 | popupPlacement="top" 80 | builtinPlacements={builtinPlacements} 81 | > 82 | 93 | Target 94 | 95 | 96 |
97 |
98 | 99 | 100 | {/*
*/} 101 | 102 | ); 103 | }; 104 | -------------------------------------------------------------------------------- /docs/examples/clip.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import Trigger from '@rc-component/trigger'; 3 | import React from 'react'; 4 | import '../../assets/index.less'; 5 | 6 | const builtinPlacements = { 7 | top: { 8 | points: ['bc', 'tc'], 9 | overflow: { 10 | adjustX: true, 11 | adjustY: true, 12 | }, 13 | offset: [0, 0], 14 | }, 15 | bottom: { 16 | points: ['tc', 'bc'], 17 | overflow: { 18 | adjustX: true, 19 | adjustY: true, 20 | }, 21 | offset: [0, 0], 22 | }, 23 | }; 24 | 25 | const popupPlacement = 'top'; 26 | 27 | export default () => { 28 | const [scale, setScale] = React.useState('1'); 29 | 30 | return ( 31 | 32 |
33 |
40 | setScale(e.target.value)} 44 | /> 45 |
46 | 47 |
60 | 75 | Popup 76 |
77 | } 78 | getPopupContainer={(n) => n.parentNode as any} 79 | popupStyle={{ boxShadow: '0 0 5px red' }} 80 | popupPlacement={popupPlacement} 81 | builtinPlacements={builtinPlacements} 82 | stretch="minWidth" 83 | > 84 | 99 | Target 100 | 101 | 102 |
103 |
104 | 105 | {/*
*/} 106 | 107 | ); 108 | }; 109 | -------------------------------------------------------------------------------- /assets/index.less: -------------------------------------------------------------------------------- 1 | @triggerPrefixCls: rc-trigger-popup; 2 | 3 | .@{triggerPrefixCls} { 4 | position: absolute; 5 | top: -9999px; 6 | left: -9999px; 7 | z-index: 1050; 8 | 9 | &-hidden { 10 | display: none; 11 | } 12 | 13 | .effect() { 14 | animation-duration: 0.3s; 15 | animation-fill-mode: both; 16 | } 17 | 18 | &-zoom-enter, 19 | &-zoom-appear { 20 | opacity: 0; 21 | animation-play-state: paused; 22 | animation-timing-function: cubic-bezier(0.18, 0.89, 0.32, 1.28); 23 | .effect(); 24 | } 25 | 26 | &-zoom-leave { 27 | .effect(); 28 | animation-play-state: paused; 29 | animation-timing-function: cubic-bezier(0.6, -0.3, 0.74, 0.05); 30 | } 31 | 32 | &-zoom-enter&-zoom-enter-active, 33 | &-zoom-appear&-zoom-appear-active { 34 | animation-name: rcTriggerZoomIn; 35 | animation-play-state: running; 36 | } 37 | 38 | &-zoom-leave&-zoom-leave-active { 39 | animation-name: rcTriggerZoomOut; 40 | animation-play-state: running; 41 | } 42 | 43 | &-arrow { 44 | z-index: 1; 45 | width: 0px; 46 | height: 0px; 47 | background: #000; 48 | border-radius: 100vw; 49 | box-shadow: 0 0 0 3px black; 50 | } 51 | 52 | @keyframes rcTriggerZoomIn { 53 | 0% { 54 | transform: scale(0, 0); 55 | transform-origin: var(--arrow-x, 50%) var(--arrow-y, 50%); 56 | opacity: 0; 57 | } 58 | 100% { 59 | transform: scale(1, 1); 60 | transform-origin: var(--arrow-x, 50%) var(--arrow-y, 50%); 61 | opacity: 1; 62 | } 63 | } 64 | @keyframes rcTriggerZoomOut { 65 | 0% { 66 | transform: scale(1, 1); 67 | transform-origin: var(--arrow-x, 50%) var(--arrow-y, 50%); 68 | opacity: 1; 69 | } 70 | 100% { 71 | transform: scale(0, 0); 72 | transform-origin: var(--arrow-x, 50%) var(--arrow-y, 50%); 73 | opacity: 0; 74 | } 75 | } 76 | 77 | // =============== Unique Container =============== 78 | &-unique-container { 79 | position: absolute; 80 | z-index: 0; 81 | box-sizing: border-box; 82 | border: 1px solid red; 83 | background: green; 84 | 85 | &-hidden { 86 | display: none; 87 | } 88 | 89 | &-visible { 90 | transition: all 0.1s; 91 | } 92 | } 93 | 94 | // Debug 95 | &-unique-controlled { 96 | border-color: rgba(0, 0, 0, 0.01) !important; 97 | background: transparent !important; 98 | z-index: 1; 99 | } 100 | 101 | // Motion Content 102 | &-motion-content { 103 | // Fade motion 104 | &-fade-appear { 105 | opacity: 0; 106 | animation-duration: 0.3s; 107 | animation-fill-mode: both; 108 | animation-timing-function: cubic-bezier(0.55, 0, 0.55, 0.2); 109 | } 110 | 111 | &-fade-appear&-fade-appear-active { 112 | animation-name: rcTriggerFadeIn; 113 | } 114 | 115 | @keyframes rcTriggerFadeIn { 116 | 0% { 117 | opacity: 0; 118 | } 119 | 100% { 120 | opacity: 1; 121 | } 122 | } 123 | } 124 | } 125 | 126 | @import './index/Mask'; 127 | @import './index/Mobile'; 128 | -------------------------------------------------------------------------------- /src/hooks/useWinClick.ts: -------------------------------------------------------------------------------- 1 | import { getShadowRoot } from '@rc-component/util/lib/Dom/shadow'; 2 | import { warning } from '@rc-component/util/lib/warning'; 3 | import * as React from 'react'; 4 | import { getWin } from '../util'; 5 | 6 | /** 7 | * Close if click on the window. 8 | * Return the function that click on the Popup element. 9 | */ 10 | export default function useWinClick( 11 | open: boolean, 12 | clickToHide: boolean, 13 | targetEle: HTMLElement, 14 | popupEle: HTMLElement, 15 | mask: boolean, 16 | maskClosable: boolean, 17 | inPopupOrChild: (target: EventTarget) => boolean, 18 | triggerOpen: (open: boolean) => void, 19 | ) { 20 | const openRef = React.useRef(open); 21 | openRef.current = open; 22 | 23 | const popupPointerDownRef = React.useRef(false); 24 | 25 | // Click to hide is special action since click popup element should not hide 26 | React.useEffect(() => { 27 | if (clickToHide && popupEle && (!mask || maskClosable)) { 28 | const onPointerDown = () => { 29 | popupPointerDownRef.current = false; 30 | }; 31 | 32 | const onTriggerClose = (e: MouseEvent) => { 33 | if ( 34 | openRef.current && 35 | !inPopupOrChild(e.composedPath?.()?.[0] || e.target) && 36 | !popupPointerDownRef.current 37 | ) { 38 | triggerOpen(false); 39 | } 40 | }; 41 | 42 | const win = getWin(popupEle); 43 | 44 | win.addEventListener('pointerdown', onPointerDown, true); 45 | win.addEventListener('mousedown', onTriggerClose, true); 46 | win.addEventListener('contextmenu', onTriggerClose, true); 47 | 48 | // shadow root 49 | const targetShadowRoot = getShadowRoot(targetEle); 50 | if (targetShadowRoot) { 51 | targetShadowRoot.addEventListener('mousedown', onTriggerClose, true); 52 | targetShadowRoot.addEventListener('contextmenu', onTriggerClose, true); 53 | } 54 | 55 | // Warning if target and popup not in same root 56 | if (process.env.NODE_ENV !== 'production' && targetEle) { 57 | const targetRoot = targetEle.getRootNode?.(); 58 | const popupRoot = popupEle.getRootNode?.(); 59 | 60 | warning( 61 | targetRoot === popupRoot, 62 | `trigger element and popup element should in same shadow root.`, 63 | ); 64 | } 65 | 66 | return () => { 67 | win.removeEventListener('pointerdown', onPointerDown, true); 68 | win.removeEventListener('mousedown', onTriggerClose, true); 69 | win.removeEventListener('contextmenu', onTriggerClose, true); 70 | 71 | if (targetShadowRoot) { 72 | targetShadowRoot.removeEventListener( 73 | 'mousedown', 74 | onTriggerClose, 75 | true, 76 | ); 77 | targetShadowRoot.removeEventListener( 78 | 'contextmenu', 79 | onTriggerClose, 80 | true, 81 | ); 82 | } 83 | }; 84 | } 85 | }, [clickToHide, targetEle, popupEle, mask, maskClosable]); 86 | 87 | function onPopupPointerDown() { 88 | popupPointerDownRef.current = true; 89 | } 90 | 91 | return onPopupPointerDown; 92 | } 93 | -------------------------------------------------------------------------------- /tests/perf.test.tsx: -------------------------------------------------------------------------------- 1 | import { cleanup, fireEvent, render } from '@testing-library/react'; 2 | import { spyElementPrototypes } from '@rc-component/util/lib/test/domHook'; 3 | import React from 'react'; 4 | import Trigger, { type TriggerProps } from '../src'; 5 | import { awaitFakeTimer, placementAlignMap } from './util'; 6 | 7 | jest.mock('../src/Popup', () => { 8 | const OriReact = jest.requireActual('react'); 9 | const OriPopup = jest.requireActual('../src/Popup').default; 10 | 11 | return OriReact.forwardRef((props, ref) => { 12 | global.popupCalledTimes = (global.popupCalledTimes || 0) + 1; 13 | return ; 14 | }); 15 | }); 16 | 17 | describe('Trigger.Basic', () => { 18 | beforeAll(() => { 19 | spyElementPrototypes(HTMLElement, { 20 | offsetParent: { 21 | get: () => document.body, 22 | }, 23 | }); 24 | }); 25 | 26 | beforeEach(() => { 27 | global.popupCalledTimes = 0; 28 | jest.useFakeTimers(); 29 | }); 30 | 31 | afterEach(() => { 32 | cleanup(); 33 | jest.useRealTimers(); 34 | }); 35 | 36 | async function trigger(dom: HTMLElement, selector: string, method = 'click') { 37 | fireEvent[method](dom.querySelector(selector)); 38 | await awaitFakeTimer(); 39 | } 40 | 41 | const renderTrigger = (props?: Partial) => ( 42 | tooltip2} 46 | {...props} 47 | > 48 |
click
49 |
50 | ); 51 | 52 | describe('Performance', () => { 53 | it('not create Popup when !open', async () => { 54 | const { container } = render(renderTrigger()); 55 | 56 | // Not render Popup 57 | await awaitFakeTimer(); 58 | expect(global.popupCalledTimes).toBe(0); 59 | 60 | // Now can render Popup 61 | await trigger(container, '.target'); 62 | expect(global.popupCalledTimes).toBeGreaterThan(0); 63 | 64 | expect(document.querySelector('.rc-trigger-popup')).toBeTruthy(); 65 | }); 66 | 67 | it('forceRender should create when !open', async () => { 68 | const { container } = render( 69 | renderTrigger({ 70 | forceRender: true, 71 | }), 72 | ); 73 | 74 | await awaitFakeTimer(); 75 | await trigger(container, '.target'); 76 | expect(global.popupCalledTimes).toBeGreaterThan(0); 77 | 78 | expect(document.querySelector('.rc-trigger-popup')).toBeTruthy(); 79 | }); 80 | 81 | it('hide should keep render Popup', async () => { 82 | const { rerender } = render( 83 | renderTrigger({ 84 | popupVisible: true, 85 | }), 86 | ); 87 | 88 | await awaitFakeTimer(); 89 | expect(global.popupCalledTimes).toBeGreaterThan(0); 90 | 91 | // Hide 92 | global.popupCalledTimes = 0; 93 | rerender( 94 | renderTrigger({ 95 | popupVisible: false, 96 | }), 97 | ); 98 | await awaitFakeTimer(); 99 | expect(global.popupCalledTimes).toBeGreaterThan(0); 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /docs/examples/visible-fallback.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import type { AlignType, TriggerRef } from '@rc-component/trigger'; 3 | import Trigger from '@rc-component/trigger'; 4 | import React from 'react'; 5 | import '../../assets/index.less'; 6 | 7 | const builtinPlacements: Record = { 8 | top: { 9 | points: ['bc', 'tc'], 10 | overflow: { 11 | adjustX: true, 12 | adjustY: true, 13 | }, 14 | offset: [0, 0], 15 | htmlRegion: 'visibleFirst', 16 | }, 17 | bottom: { 18 | points: ['tc', 'bc'], 19 | overflow: { 20 | adjustX: true, 21 | adjustY: true, 22 | }, 23 | offset: [0, 0], 24 | htmlRegion: 'visibleFirst', 25 | }, 26 | }; 27 | 28 | export default () => { 29 | const [enoughTop, setEnoughTop] = React.useState(true); 30 | 31 | const triggerRef = React.useRef(null); 32 | 33 | React.useEffect(() => { 34 | triggerRef.current?.forceAlign(); 35 | }, [enoughTop]); 36 | 37 | return ( 38 | 39 |

`visibleFirst` should not show in hidden region if still scrollable

40 | 41 | 49 | 50 |
62 | 78 | Should Always place bottom 79 |
80 | } 81 | getPopupContainer={(n) => n.parentNode as any} 82 | popupStyle={{ boxShadow: '0 0 5px red' }} 83 | popupPlacement={enoughTop ? 'bottom' : 'top'} 84 | builtinPlacements={builtinPlacements} 85 | stretch="minWidth" 86 | > 87 | 103 | Target 104 | 105 | 106 |
107 |
108 | ); 109 | }; 110 | -------------------------------------------------------------------------------- /tests/mobile.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, fireEvent, render } from '@testing-library/react'; 2 | import isMobile from '@rc-component/util/lib/isMobile'; 3 | import React from 'react'; 4 | import Trigger, { type TriggerProps } from '../src'; 5 | import { placementAlignMap } from './util'; 6 | 7 | jest.mock('@rc-component/util/lib/isMobile'); 8 | 9 | describe('Trigger.Mobile', () => { 10 | beforeAll(() => { 11 | (isMobile as any).mockImplementation(() => true); 12 | }); 13 | 14 | beforeEach(() => { 15 | jest.useFakeTimers(); 16 | }); 17 | 18 | afterEach(() => { 19 | jest.clearAllTimers(); 20 | jest.useRealTimers(); 21 | }); 22 | 23 | function flush() { 24 | act(() => { 25 | jest.runAllTimers(); 26 | }); 27 | } 28 | 29 | it('auto change hover to click', () => { 30 | render( 31 | trigger} 34 | > 35 |
36 | , 37 | ); 38 | 39 | const target = document.querySelector('.target'); 40 | 41 | flush(); 42 | expect(document.querySelector('.rc-trigger-popup')).toBeFalsy(); 43 | 44 | // Touch work 45 | fireEvent.touchStart(target); 46 | fireEvent.mouseEnter(target); 47 | fireEvent.mouseLeave(target); 48 | flush(); 49 | expect(document.querySelector('.rc-trigger-popup')).toBeTruthy(); 50 | 51 | // Touch again 52 | fireEvent.touchStart(target); 53 | flush(); 54 | expect(document.querySelector('.rc-trigger-popup-hidden')).toBeTruthy(); 55 | }); 56 | 57 | // ==================================================================================== 58 | function getTrigger(props?: Partial) { 59 | return ( 60 | } 64 | mask 65 | maskClosable 66 | mobile={{}} 67 | {...props} 68 | > 69 |
click
70 |
71 | ); 72 | } 73 | 74 | describe('mobile config', () => { 75 | it('enabled', () => { 76 | const { container } = render( 77 | getTrigger({ 78 | mobile: {}, 79 | }), 80 | ); 81 | 82 | fireEvent.click(container.querySelector('.target')); 83 | 84 | expect(document.querySelector('.rc-trigger-popup')).toHaveClass( 85 | 'rc-trigger-popup-mobile', 86 | ); 87 | }); 88 | 89 | it('replace motion', () => { 90 | render( 91 | getTrigger({ 92 | mobile: { 93 | motion: { 94 | motionName: 'bamboo', 95 | }, 96 | mask: true, 97 | maskMotion: { 98 | motionName: 'little', 99 | }, 100 | }, 101 | popupVisible: true, 102 | }), 103 | ); 104 | 105 | expect(document.querySelector('.rc-trigger-popup-mobile')).toBeTruthy(); 106 | expect( 107 | document.querySelector('.rc-trigger-popup-mobile-mask'), 108 | ).toBeTruthy(); 109 | 110 | expect(document.querySelector('.rc-trigger-popup')).toHaveClass('bamboo'); 111 | expect(document.querySelector('.rc-trigger-popup-mask')).toHaveClass( 112 | 'little', 113 | ); 114 | }); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /src/UniqueProvider/UniqueContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import useOffsetStyle from '../hooks/useOffsetStyle'; 3 | import { clsx } from 'clsx'; 4 | import CSSMotion from '@rc-component/motion'; 5 | import type { CSSMotionProps } from '@rc-component/motion'; 6 | import type { AlignType, ArrowPos } from '../interface'; 7 | 8 | export interface UniqueContainerProps { 9 | prefixCls: string; // ${prefixCls}-unique-container 10 | isMobile: boolean; 11 | ready: boolean; 12 | open: boolean; 13 | align: AlignType; 14 | offsetR: number; 15 | offsetB: number; 16 | offsetX: number; 17 | offsetY: number; 18 | arrowPos?: ArrowPos; 19 | popupSize?: { width: number; height: number }; 20 | motion?: CSSMotionProps; 21 | uniqueContainerClassName?: string; 22 | uniqueContainerStyle?: React.CSSProperties; 23 | } 24 | 25 | const UniqueContainer = (props: UniqueContainerProps) => { 26 | const { 27 | prefixCls, 28 | isMobile, 29 | ready, 30 | open, 31 | align, 32 | offsetR, 33 | offsetB, 34 | offsetX, 35 | offsetY, 36 | arrowPos, 37 | popupSize, 38 | motion, 39 | uniqueContainerClassName, 40 | uniqueContainerStyle, 41 | } = props; 42 | 43 | const containerCls = `${prefixCls}-unique-container`; 44 | 45 | const [motionVisible, setMotionVisible] = React.useState(false); 46 | 47 | // ========================= Styles ========================= 48 | const offsetStyle = useOffsetStyle( 49 | isMobile, 50 | ready, 51 | open, 52 | align, 53 | offsetR, 54 | offsetB, 55 | offsetX, 56 | offsetY, 57 | ); 58 | 59 | // Cache for offsetStyle when ready is true 60 | const cachedOffsetStyleRef = React.useRef(offsetStyle); 61 | 62 | // Update cached offset style when ready is true 63 | if (ready) { 64 | cachedOffsetStyleRef.current = offsetStyle; 65 | } 66 | 67 | // Apply popup size if available 68 | const sizeStyle: React.CSSProperties = {}; 69 | if (popupSize) { 70 | sizeStyle.width = popupSize.width; 71 | sizeStyle.height = popupSize.height; 72 | } 73 | 74 | // ========================= Render ========================= 75 | return ( 76 | { 85 | setMotionVisible(nextVisible); 86 | }} 87 | > 88 | {({ className: motionClassName, style: motionStyle }) => { 89 | const cls = clsx( 90 | containerCls, 91 | motionClassName, 92 | uniqueContainerClassName, 93 | { [`${containerCls}-visible`]: motionVisible }, 94 | ); 95 | 96 | return ( 97 |
110 | ); 111 | }} 112 | 113 | ); 114 | }; 115 | 116 | export default UniqueContainer; 117 | -------------------------------------------------------------------------------- /docs/examples/nested.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import Trigger from '@rc-component/trigger'; 6 | import '../../assets/index.less'; 7 | 8 | const builtinPlacements = { 9 | left: { 10 | points: ['cr', 'cl'], 11 | }, 12 | right: { 13 | points: ['cl', 'cr'], 14 | }, 15 | top: { 16 | points: ['bc', 'tc'], 17 | }, 18 | bottom: { 19 | points: ['tc', 'bc'], 20 | }, 21 | topLeft: { 22 | points: ['bl', 'tl'], 23 | }, 24 | topRight: { 25 | points: ['br', 'tr'], 26 | }, 27 | bottomRight: { 28 | points: ['tr', 'br'], 29 | }, 30 | bottomLeft: { 31 | points: ['tl', 'bl'], 32 | }, 33 | }; 34 | 35 | const popupBorderStyle = { 36 | border: '1px solid red', 37 | padding: 10, 38 | }; 39 | 40 | const OuterContent = ({ getContainer }) => { 41 | return ReactDOM.createPortal( 42 |
43 | I am outer content 44 | 51 |
, 52 | getContainer(), 53 | ); 54 | }; 55 | 56 | const Test = () => { 57 | const containerRef = React.useRef(null); 58 | const outerDivRef = React.useRef(null); 59 | 60 | const innerTrigger = ( 61 |
62 |
63 | containerRef.current} 68 | popup={
I am inner Trigger Popup
} 69 | > 70 | clickToShowInnerTrigger 71 |
72 |
73 | ); 74 | return ( 75 |
76 |
77 | 83 | i am a click popup 84 | outerDivRef.current} /> 85 |
86 | } 87 | > 88 | 89 | i am a hover popup
} 94 | > 95 | trigger 96 | 97 | 98 | 99 |
100 |
101 | 107 | trigger 108 | 109 |
110 | 111 |
122 |
123 | ); 124 | }; 125 | 126 | export default Test; 127 | -------------------------------------------------------------------------------- /src/interface.ts: -------------------------------------------------------------------------------- 1 | export type Placement = 2 | | 'top' 3 | | 'left' 4 | | 'right' 5 | | 'bottom' 6 | | 'topLeft' 7 | | 'topRight' 8 | | 'bottomLeft' 9 | | 'bottomRight' 10 | | 'leftTop' 11 | | 'leftBottom' 12 | | 'rightTop' 13 | | 'rightBottom'; 14 | 15 | export type AlignPointTopBottom = 't' | 'b' | 'c'; 16 | export type AlignPointLeftRight = 'l' | 'r' | 'c'; 17 | 18 | /** Two char of 't' 'b' 'c' 'l' 'r'. Example: 'lt' */ 19 | export type AlignPoint = `${AlignPointTopBottom}${AlignPointLeftRight}`; 20 | 21 | export type OffsetType = number | `${number}%`; 22 | 23 | export interface AlignType { 24 | /** 25 | * move point of source node to align with point of target node. 26 | * Such as ['tr','cc'], align top right point of source node with center point of target node. 27 | * Point can be 't'(top), 'b'(bottom), 'c'(center), 'l'(left), 'r'(right) */ 28 | points?: (string | AlignPoint)[]; 29 | 30 | /** 31 | * @private Do not use in your production code 32 | */ 33 | _experimental?: Record; 34 | 35 | /** 36 | * offset source node by offset[0] in x and offset[1] in y. 37 | * If offset contains percentage string value, it is relative to sourceNode region. 38 | */ 39 | offset?: OffsetType[]; 40 | /** 41 | * offset target node by offset[0] in x and offset[1] in y. 42 | * If targetOffset contains percentage string value, it is relative to targetNode region. 43 | */ 44 | targetOffset?: OffsetType[]; 45 | /** 46 | * If adjustX field is true, will adjust source node in x direction if source node is invisible. 47 | * If adjustY field is true, will adjust source node in y direction if source node is invisible. 48 | */ 49 | overflow?: { 50 | adjustX?: boolean | number; 51 | adjustY?: boolean | number; 52 | shiftX?: boolean | number; 53 | shiftY?: boolean | number; 54 | }; 55 | /** Auto adjust arrow position */ 56 | autoArrow?: boolean; 57 | /** 58 | * Config visible region check of html node. Default `visible`: 59 | * - `visible`: 60 | * The visible region of user browser window. 61 | * Use `clientHeight` for check. 62 | * If `visible` region not satisfy, fallback to `scroll`. 63 | * - `scroll`: 64 | * The whole region of the html scroll area. 65 | * Use `scrollHeight` for check. 66 | * - `visibleFirst`: 67 | * Similar to `visible`, but if `visible` region not satisfy, fallback to `scroll`. 68 | */ 69 | htmlRegion?: 'visible' | 'scroll' | 'visibleFirst'; 70 | 71 | /** 72 | * Auto chose position with `top` or `bottom` by the align result 73 | */ 74 | dynamicInset?: boolean; 75 | /** 76 | * Whether use css right instead of left to position 77 | */ 78 | useCssRight?: boolean; 79 | /** 80 | * Whether use css bottom instead of top to position 81 | */ 82 | useCssBottom?: boolean; 83 | /** 84 | * Whether use css transform instead of left/top/right/bottom to position if browser supports. 85 | * Defaults to false. 86 | */ 87 | useCssTransform?: boolean; 88 | ignoreShake?: boolean; 89 | } 90 | 91 | export interface ArrowTypeOuter { 92 | style?: React.CSSProperties; 93 | className?: string; 94 | content?: React.ReactNode; 95 | } 96 | 97 | export type ArrowPos = { 98 | x?: number; 99 | y?: number; 100 | }; 101 | 102 | export type BuildInPlacements = Record; 103 | 104 | export type StretchType = string; 105 | 106 | export type ActionType = 'hover' | 'focus' | 'click' | 'contextMenu'; 107 | 108 | export type AnimationType = string; 109 | 110 | export type TransitionNameType = string; 111 | 112 | export interface Point { 113 | pageX: number; 114 | pageY: number; 115 | } 116 | 117 | export interface CommonEventHandler { 118 | remove: () => void; 119 | } 120 | -------------------------------------------------------------------------------- /docs/examples/two-buttons.tsx: -------------------------------------------------------------------------------- 1 | import Trigger, { UniqueProvider } from '@rc-component/trigger'; 2 | import React, { useState } from 'react'; 3 | import '../../assets/index.less'; 4 | 5 | const LEAVE_DELAY = 0.2; 6 | 7 | const builtinPlacements = { 8 | left: { 9 | points: ['cr', 'cl'], 10 | offset: [-10, 0], 11 | }, 12 | right: { 13 | points: ['cl', 'cr'], 14 | offset: [10, 0], 15 | }, 16 | top: { 17 | points: ['bc', 'tc'], 18 | offset: [0, -10], 19 | }, 20 | bottom: { 21 | points: ['tc', 'bc'], 22 | offset: [0, 10], 23 | }, 24 | }; 25 | 26 | const MovingPopupDemo = () => { 27 | const [useUniqueProvider, setUseUniqueProvider] = useState(true); 28 | const [triggerControl, setTriggerControl] = useState('none'); // 'button1', 'button2', 'none' 29 | 30 | const getVisible = (name: string) => { 31 | if (triggerControl === 'none') { 32 | return undefined; 33 | } 34 | if (triggerControl === name) { 35 | return true; 36 | } 37 | return false; 38 | }; 39 | 40 | const content = ( 41 |
42 |
43 | 这是左侧按钮的提示信息
} 53 | popupStyle={{ 54 | border: '1px solid #ccc', 55 | padding: 10, 56 | background: 'white', 57 | boxSizing: 'border-box', 58 | }} 59 | unique 60 | > 61 | 62 | 63 | 64 | This is the tooltip for the right button
} 74 | popupStyle={{ 75 | border: '1px solid #ccc', 76 | padding: 10, 77 | background: 'white', 78 | boxSizing: 'border-box', 79 | }} 80 | unique 81 | > 82 | 83 | 84 |
85 | 86 |
87 | 95 |
96 | 97 |
98 |
Trigger 控制:
99 | 109 | 119 | 129 |
130 |
131 | ); 132 | 133 | return useUniqueProvider ? ( 134 | {content} 135 | ) : ( 136 | content 137 | ); 138 | }; 139 | 140 | export default MovingPopupDemo; 141 | -------------------------------------------------------------------------------- /tests/motion.test.jsx: -------------------------------------------------------------------------------- 1 | import { act, cleanup, fireEvent, render } from '@testing-library/react'; 2 | import Trigger from '../src'; 3 | import { placementAlignMap } from './util'; 4 | 5 | describe('Trigger.Motion', () => { 6 | beforeEach(() => { 7 | jest.useFakeTimers(); 8 | }); 9 | 10 | afterEach(() => { 11 | cleanup(); 12 | jest.useRealTimers(); 13 | }); 14 | 15 | async function awaitFakeTimer() { 16 | for (let i = 0; i < 10; i += 1) { 17 | await act(async () => { 18 | jest.advanceTimersByTime(100); 19 | await Promise.resolve(); 20 | }); 21 | } 22 | } 23 | 24 | it('popup should support motion', async () => { 25 | const { container } = render( 26 | } 30 | popupMotion={{ 31 | motionName: 'bamboo', 32 | }} 33 | > 34 |
click
35 |
, 36 | ); 37 | const target = container.querySelector('.target'); 38 | 39 | fireEvent.click(target); 40 | 41 | expect(document.querySelector('.rc-trigger-popup')).toHaveClass( 42 | 'bamboo-appear', 43 | ); 44 | 45 | expect( 46 | document.querySelector('.rc-trigger-popup').style.pointerEvents, 47 | ).toEqual(''); 48 | }); 49 | 50 | it('use correct leave motion', async () => { 51 | // const cssMotionSpy = jest.spyOn(CSSMotion, 'render'); 52 | 53 | const renderDemo = (props) => ( 54 | } 58 | popupMotion={{ 59 | motionName: 'bamboo', 60 | leavedClassName: 'light', 61 | motionDeadline: 300, 62 | }} 63 | {...props} 64 | > 65 |
click
66 |
67 | ); 68 | 69 | const { rerender } = render(renderDemo({ popupVisible: true })); 70 | await awaitFakeTimer(); 71 | 72 | rerender(renderDemo({ popupVisible: false })); 73 | await awaitFakeTimer(); 74 | 75 | expect(document.querySelector('.rc-trigger-popup')).toHaveClass('light'); 76 | }); 77 | 78 | it('not lock on appear', () => { 79 | const genTrigger = (props) => ( 80 | } 82 | popupMotion={{ 83 | motionName: 'bamboo', 84 | }} 85 | popupVisible 86 | {...props} 87 | > 88 | 89 | 90 | ); 91 | 92 | const { rerender } = render(genTrigger()); 93 | 94 | expect(document.querySelector('.rc-trigger-popup')).not.toHaveStyle({ 95 | pointerEvents: 'none', 96 | }); 97 | 98 | rerender(genTrigger({ popupVisible: false })); 99 | expect(document.querySelector('.rc-trigger-popup')).toHaveStyle({ 100 | pointerEvents: 'none', 101 | }); 102 | }); 103 | 104 | it('no update when close', () => { 105 | const genTrigger = ({ children, ...props }) => ( 106 | 114 | 115 | 116 | ); 117 | 118 | const { rerender } = render( 119 | genTrigger({ 120 | children:
, 121 | }), 122 | ); 123 | 124 | expect(document.querySelector('.bamboo')).toBeTruthy(); 125 | 126 | // rerender when open 127 | rerender( 128 | genTrigger({ 129 | children:
, 130 | }), 131 | ); 132 | expect(document.querySelector('.little')).toBeTruthy(); 133 | 134 | // rerender when close 135 | rerender( 136 | genTrigger({ 137 | popupVisible: false, 138 | children:
, 139 | }), 140 | ); 141 | expect(document.querySelector('.little')).toBeTruthy(); 142 | }); 143 | 144 | it('keep motion config update when motion ready', () => { 145 | const genTrigger = (props) => ( 146 | } popupMotion={{}} popupVisible {...props}> 147 | 148 | 149 | ); 150 | 151 | const { rerender } = render(genTrigger()); 152 | expect(document.querySelector('.bamboo')).toBeFalsy(); 153 | 154 | rerender( 155 | genTrigger({ 156 | popupMotion: { motionName: 'bamboo' }, 157 | }), 158 | ); 159 | expect(document.querySelector('.bamboo')).toBeTruthy(); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /docs/examples/inside.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import React from 'react'; 3 | import '../../assets/index.less'; 4 | import Trigger, { type BuildInPlacements } from '../../src'; 5 | 6 | const experimentalConfig = { 7 | _experimental: { 8 | dynamicInset: true, 9 | }, 10 | }; 11 | 12 | export const builtinPlacements: BuildInPlacements = { 13 | top: { 14 | points: ['bc', 'tc'], 15 | overflow: { 16 | shiftX: 0, 17 | adjustY: true, 18 | }, 19 | offset: [0, 0], 20 | ...experimentalConfig, 21 | }, 22 | topLeft: { 23 | points: ['bl', 'tl'], 24 | overflow: { 25 | adjustX: true, 26 | adjustY: false, 27 | shiftY: true, 28 | }, 29 | offset: [0, -20], 30 | ...experimentalConfig, 31 | }, 32 | topRight: { 33 | points: ['br', 'tr'], 34 | overflow: { 35 | adjustX: true, 36 | adjustY: true, 37 | }, 38 | offset: [0, 0], 39 | ...experimentalConfig, 40 | }, 41 | left: { 42 | points: ['cr', 'cl'], 43 | overflow: { 44 | adjustX: true, 45 | shiftY: true, 46 | }, 47 | offset: [0, 0], 48 | ...experimentalConfig, 49 | }, 50 | leftTop: { 51 | points: ['tr', 'tl'], 52 | overflow: { 53 | adjustX: true, 54 | adjustY: true, 55 | }, 56 | offset: [0, 0], 57 | ...experimentalConfig, 58 | }, 59 | leftBottom: { 60 | points: ['br', 'bl'], 61 | overflow: { 62 | adjustX: true, 63 | adjustY: true, 64 | }, 65 | offset: [0, 0], 66 | ...experimentalConfig, 67 | }, 68 | right: { 69 | points: ['cl', 'cr'], 70 | overflow: { 71 | adjustX: true, 72 | shiftY: true, 73 | }, 74 | offset: [0, 0], 75 | ...experimentalConfig, 76 | }, 77 | bottom: { 78 | points: ['tc', 'bc'], 79 | overflow: { 80 | shiftX: 50, 81 | adjustY: true, 82 | }, 83 | offset: [0, 0], 84 | ...experimentalConfig, 85 | }, 86 | bottomLeft: { 87 | points: ['tl', 'bl'], 88 | overflow: { 89 | shiftX: 50, 90 | adjustY: true, 91 | shiftY: true, 92 | }, 93 | offset: [0, 20], 94 | ...experimentalConfig, 95 | }, 96 | }; 97 | 98 | const popupPlacement = 'bottomLeft'; 99 | 100 | export default () => { 101 | const [popupHeight, setPopupHeight] = React.useState(60); 102 | 103 | const containerRef = React.useRef(null); 104 | 105 | React.useEffect(() => { 106 | containerRef.current.scrollLeft = document.defaultView.innerWidth; 107 | containerRef.current.scrollTop = document.defaultView.innerHeight; 108 | }, []); 109 | 110 | return ( 111 | <> 112 |
113 | 120 |
121 |
130 |
142 | 154 | Popup 155 |
156 | } 157 | popupVisible 158 | getPopupContainer={() => containerRef.current} 159 | popupPlacement={popupPlacement} 160 | builtinPlacements={builtinPlacements} 161 | > 162 | 173 | Target 174 | 175 | 176 |
177 |
178 | 179 | ); 180 | }; 181 | -------------------------------------------------------------------------------- /docs/examples/container.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import Trigger from '@rc-component/trigger'; 3 | import React from 'react'; 4 | import '../../assets/index.less'; 5 | 6 | const builtinPlacements = { 7 | topLeft: { 8 | points: ['bl', 'tl'], 9 | overflow: { 10 | shiftX: 50, 11 | adjustY: true, 12 | }, 13 | offset: [0, 0], 14 | targetOffset: [10, 0], 15 | }, 16 | bottomLeft: { 17 | points: ['tl', 'bl'], 18 | overflow: { 19 | adjustX: true, 20 | adjustY: true, 21 | }, 22 | }, 23 | top: { 24 | points: ['bc', 'tc'], 25 | overflow: { 26 | shiftX: 50, 27 | adjustY: true, 28 | }, 29 | offset: [0, -10], 30 | }, 31 | bottom: { 32 | points: ['tc', 'bc'], 33 | overflow: { 34 | shiftX: true, 35 | adjustY: true, 36 | }, 37 | offset: [0, 10], 38 | htmlRegion: 'scroll' as const, 39 | }, 40 | left: { 41 | points: ['cr', 'cl'], 42 | overflow: { 43 | adjustX: true, 44 | shiftY: true, 45 | }, 46 | offset: [-10, 0], 47 | }, 48 | right: { 49 | points: ['cl', 'cr'], 50 | overflow: { 51 | adjustX: true, 52 | shiftY: 24, 53 | }, 54 | offset: [10, 0], 55 | }, 56 | }; 57 | 58 | const popupPlacement = 'top'; 59 | 60 | export default () => { 61 | console.log('Demo Render!'); 62 | 63 | const [visible, setVisible] = React.useState(false); 64 | const [scale, setScale] = React.useState('1'); 65 | const [targetVisible, setTargetVisible] = React.useState(true); 66 | 67 | const rootRef = React.useRef(null); 68 | const popHolderRef = React.useRef(null); 69 | const scrollRef = React.useRef(null); 70 | 71 | React.useEffect(() => { 72 | scrollRef.current.scrollLeft = window.innerWidth; 73 | scrollRef.current.scrollTop = window.innerHeight / 2; 74 | }, []); 75 | 76 | return ( 77 | 78 |
83 |
91 | setScale(e.target.value)} 95 | /> 96 | 104 | 111 |
112 |
125 |
136 |
146 | 160 | Popup 161 |
162 | } 163 | popupMotion={{ 164 | motionName: 'rc-trigger-popup-zoom', 165 | }} 166 | popupStyle={{ boxShadow: '0 0 5px red' }} 167 | popupVisible={visible} 168 | onOpenChange={(nextVisible) => { 169 | setVisible(nextVisible); 170 | }} 171 | // getPopupContainer={() => popHolderRef.current} 172 | popupPlacement={popupPlacement} 173 | builtinPlacements={builtinPlacements} 174 | stretch="minWidth" 175 | onPopupAlign={(domNode, align) => { 176 | console.log('onPopupAlign:', domNode, align); 177 | }} 178 | > 179 | 190 | Target 191 | 192 | 193 |
194 |
195 |
196 | 197 | {/*
*/} 198 | 199 | ); 200 | }; 201 | -------------------------------------------------------------------------------- /tests/flipShift.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, cleanup, render } from '@testing-library/react'; 2 | import { spyElementPrototypes } from '@rc-component/util/lib/test/domHook'; 3 | import React from 'react'; 4 | import Trigger from '../src'; 5 | 6 | /* 7 | *********** 8 | ****************** * * 9 | * Placement * * Popup * 10 | * ********** * * * 11 | * * Target * * * * 12 | * ********** * *********** 13 | * * 14 | * * 15 | ****************** 16 | 17 | When `placement` is `top`. It will find should flip to bottom: 18 | 19 | ****************** 20 | * * 21 | * ********** * 22 | * * Target * * 23 | * ********** * *********** top: 200 24 | * Placement * * * 25 | * * * Popup * 26 | ****************** * * 27 | * * 28 | *********** 29 | 30 | When `placement` is `bottom`. It will find should shift to show in viewport: 31 | 32 | ****************** 33 | * * 34 | * ********** * *********** top: 100 35 | * * Target * * * * 36 | * ********** * * Popup * 37 | * Placement * * * 38 | * * * * 39 | ****************** *********** 40 | 41 | */ 42 | 43 | const builtinPlacements = { 44 | top: { 45 | points: ['bc', 'tc'], 46 | overflow: { 47 | adjustY: true, 48 | shiftY: true, 49 | }, 50 | }, 51 | topShift: { 52 | points: ['bc', 'tc'], 53 | overflow: { 54 | shiftX: true, 55 | }, 56 | htmlRegion: 'visibleFirst' as const, 57 | }, 58 | bottom: { 59 | points: ['tc', 'bc'], 60 | overflow: { 61 | adjustY: true, 62 | shiftY: true, 63 | }, 64 | }, 65 | left: { 66 | points: ['cr', 'cl'], 67 | overflow: { 68 | adjustX: true, 69 | shiftX: true, 70 | }, 71 | }, 72 | right: { 73 | points: ['cl', 'cr'], 74 | overflow: { 75 | adjustX: true, 76 | shiftX: true, 77 | }, 78 | }, 79 | }; 80 | 81 | describe('Trigger.Flip+Shift', () => { 82 | let spanRect = { x: 0, y: 0, left: 0, top: 0, width: 0, height: 0 }; 83 | 84 | beforeEach(() => { 85 | spanRect = { 86 | x: 0, 87 | y: 100, 88 | left: 0, 89 | top: 100, 90 | width: 100, 91 | height: 100, 92 | }; 93 | 94 | document.documentElement.scrollLeft = 0; 95 | }); 96 | 97 | beforeAll(() => { 98 | jest 99 | .spyOn(document.documentElement, 'scrollWidth', 'get') 100 | .mockReturnValue(1000); 101 | 102 | // Viewport size 103 | spyElementPrototypes(HTMLElement, { 104 | clientWidth: { 105 | get: () => 400, 106 | }, 107 | clientHeight: { 108 | get: () => 400, 109 | }, 110 | }); 111 | 112 | // Popup size 113 | spyElementPrototypes(HTMLDivElement, { 114 | getBoundingClientRect() { 115 | return { 116 | x: 0, 117 | y: 0, 118 | left: 0, 119 | top: 0, 120 | width: 100, 121 | height: 300, 122 | }; 123 | }, 124 | }); 125 | spyElementPrototypes(HTMLSpanElement, { 126 | getBoundingClientRect() { 127 | return spanRect; 128 | }, 129 | }); 130 | spyElementPrototypes(HTMLElement, { 131 | offsetParent: { 132 | get: () => document.body, 133 | }, 134 | }); 135 | }); 136 | 137 | beforeEach(() => { 138 | jest.useFakeTimers(); 139 | }); 140 | 141 | afterEach(() => { 142 | cleanup(); 143 | jest.useRealTimers(); 144 | }); 145 | 146 | it('both work', async () => { 147 | render( 148 | trigger} 153 | > 154 | 155 | , 156 | ); 157 | 158 | await act(async () => { 159 | await Promise.resolve(); 160 | }); 161 | 162 | expect( 163 | document.querySelector('.rc-trigger-popup-placement-bottom'), 164 | ).toBeTruthy(); 165 | 166 | expect( 167 | document.querySelector('.rc-trigger-popup-placement-bottom'), 168 | ).toHaveStyle({ 169 | top: '100px', 170 | }); 171 | }); 172 | 173 | it('top with visibleFirst region', async () => { 174 | spanRect.x = -1000; 175 | document.documentElement.scrollLeft = 500; 176 | 177 | render( 178 | trigger} 183 | > 184 | 185 | , 186 | ); 187 | 188 | await act(async () => { 189 | await Promise.resolve(); 190 | }); 191 | 192 | // Just need check left < 0 193 | expect(document.querySelector('.rc-trigger-popup')).toHaveStyle({ 194 | left: '-900px', 195 | }); 196 | }); 197 | 198 | // https://github.com/ant-design/ant-design/issues/44096 199 | // Note: Safe to modify `top` style compare if refactor 200 | it('flip not shake by offset with shift', async () => { 201 | spanRect.y = -1000; 202 | 203 | render( 204 | trigger} 215 | > 216 | 217 | , 218 | ); 219 | 220 | await act(async () => { 221 | await Promise.resolve(); 222 | }); 223 | 224 | // Just need check left < 0 225 | expect(document.querySelector('.rc-trigger-popup')).toHaveStyle({ 226 | top: '-867px', 227 | }); 228 | }); 229 | }); 230 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AlignType, 3 | BuildInPlacements, 4 | } from './interface'; 5 | 6 | function isPointsEq( 7 | a1: string[] = [], 8 | a2: string[] = [], 9 | isAlignPoint: boolean, 10 | ): boolean { 11 | if (isAlignPoint) { 12 | return a1[0] === a2[0]; 13 | } 14 | return a1[0] === a2[0] && a1[1] === a2[1]; 15 | } 16 | 17 | export function getAlignPopupClassName( 18 | builtinPlacements: BuildInPlacements, 19 | prefixCls: string, 20 | align: AlignType, 21 | isAlignPoint: boolean, 22 | ): string { 23 | const { points } = align; 24 | 25 | const placements = Object.keys(builtinPlacements); 26 | 27 | for (let i = 0; i < placements.length; i += 1) { 28 | const placement = placements[i]; 29 | if ( 30 | isPointsEq(builtinPlacements[placement]?.points, points, isAlignPoint) 31 | ) { 32 | return `${prefixCls}-placement-${placement}`; 33 | } 34 | } 35 | 36 | return ''; 37 | } 38 | 39 | export function getWin(ele: HTMLElement) { 40 | return ele.ownerDocument.defaultView; 41 | } 42 | 43 | /** 44 | * Get all the scrollable parent elements of the element 45 | * @param ele The element to be detected 46 | * @param areaOnly Only return the parent which will cut visible area 47 | */ 48 | export function collectScroller(ele: HTMLElement) { 49 | const scrollerList: HTMLElement[] = []; 50 | let current = ele?.parentElement; 51 | 52 | const scrollStyle = ['hidden', 'scroll', 'clip', 'auto']; 53 | 54 | while (current) { 55 | const { overflowX, overflowY, overflow } = 56 | getWin(current).getComputedStyle(current); 57 | if ([overflowX, overflowY, overflow].some((o) => scrollStyle.includes(o))) { 58 | scrollerList.push(current); 59 | } 60 | 61 | current = current.parentElement; 62 | } 63 | 64 | return scrollerList; 65 | } 66 | 67 | export function toNum(num: number, defaultValue = 1) { 68 | return Number.isNaN(num) ? defaultValue : num; 69 | } 70 | 71 | function getPxValue(val: string) { 72 | return toNum(parseFloat(val), 0); 73 | } 74 | 75 | export interface VisibleArea { 76 | left: number; 77 | top: number; 78 | right: number; 79 | bottom: number; 80 | } 81 | 82 | /** 83 | * 84 | * 85 | * ************************************** 86 | * * Border * 87 | * * ************************** * 88 | * * * * * * 89 | * * B * * S * B * 90 | * * o * * c * o * 91 | * * r * Content * r * r * 92 | * * d * * o * d * 93 | * * e * * l * e * 94 | * * r ******************** l * r * 95 | * * * Scroll * * 96 | * * ************************** * 97 | * * Border * 98 | * ************************************** 99 | * 100 | */ 101 | /** 102 | * Get visible area of element 103 | */ 104 | export function getVisibleArea( 105 | initArea: VisibleArea, 106 | scrollerList?: HTMLElement[], 107 | ) { 108 | const visibleArea = { ...initArea }; 109 | 110 | (scrollerList || []).forEach((ele) => { 111 | if (ele instanceof HTMLBodyElement || ele instanceof HTMLHtmlElement) { 112 | return; 113 | } 114 | 115 | // Skip if static position which will not affect visible area 116 | const { 117 | overflow, 118 | overflowClipMargin, 119 | borderTopWidth, 120 | borderBottomWidth, 121 | borderLeftWidth, 122 | borderRightWidth, 123 | } = getWin(ele).getComputedStyle(ele); 124 | 125 | const eleRect = ele.getBoundingClientRect(); 126 | const { 127 | offsetHeight: eleOutHeight, 128 | clientHeight: eleInnerHeight, 129 | offsetWidth: eleOutWidth, 130 | clientWidth: eleInnerWidth, 131 | } = ele; 132 | 133 | const borderTopNum = getPxValue(borderTopWidth); 134 | const borderBottomNum = getPxValue(borderBottomWidth); 135 | const borderLeftNum = getPxValue(borderLeftWidth); 136 | const borderRightNum = getPxValue(borderRightWidth); 137 | 138 | const scaleX = toNum( 139 | Math.round((eleRect.width / eleOutWidth) * 1000) / 1000, 140 | ); 141 | const scaleY = toNum( 142 | Math.round((eleRect.height / eleOutHeight) * 1000) / 1000, 143 | ); 144 | 145 | // Original visible area 146 | const eleScrollWidth = 147 | (eleOutWidth - eleInnerWidth - borderLeftNum - borderRightNum) * scaleX; 148 | const eleScrollHeight = 149 | (eleOutHeight - eleInnerHeight - borderTopNum - borderBottomNum) * scaleY; 150 | 151 | // Cut border size 152 | const scaledBorderTopWidth = borderTopNum * scaleY; 153 | const scaledBorderBottomWidth = borderBottomNum * scaleY; 154 | const scaledBorderLeftWidth = borderLeftNum * scaleX; 155 | const scaledBorderRightWidth = borderRightNum * scaleX; 156 | 157 | // Clip margin 158 | let clipMarginWidth = 0; 159 | let clipMarginHeight = 0; 160 | if (overflow === 'clip') { 161 | const clipNum = getPxValue(overflowClipMargin); 162 | clipMarginWidth = clipNum * scaleX; 163 | clipMarginHeight = clipNum * scaleY; 164 | } 165 | 166 | // Region 167 | const eleLeft = eleRect.x + scaledBorderLeftWidth - clipMarginWidth; 168 | const eleTop = eleRect.y + scaledBorderTopWidth - clipMarginHeight; 169 | 170 | const eleRight = 171 | eleLeft + 172 | eleRect.width + 173 | 2 * clipMarginWidth - 174 | scaledBorderLeftWidth - 175 | scaledBorderRightWidth - 176 | eleScrollWidth; 177 | 178 | const eleBottom = 179 | eleTop + 180 | eleRect.height + 181 | 2 * clipMarginHeight - 182 | scaledBorderTopWidth - 183 | scaledBorderBottomWidth - 184 | eleScrollHeight; 185 | 186 | visibleArea.left = Math.max(visibleArea.left, eleLeft); 187 | visibleArea.top = Math.max(visibleArea.top, eleTop); 188 | visibleArea.right = Math.min(visibleArea.right, eleRight); 189 | visibleArea.bottom = Math.min(visibleArea.bottom, eleBottom); 190 | }); 191 | 192 | return visibleArea; 193 | } 194 | -------------------------------------------------------------------------------- /tests/point.test.jsx: -------------------------------------------------------------------------------- 1 | import { act, cleanup, fireEvent, render } from '@testing-library/react'; 2 | import React from 'react'; 3 | import Trigger from '../src'; 4 | import { getMouseEvent } from './util'; 5 | 6 | /** 7 | * dom-align internal default position is `-999`. 8 | * We do not need to simulate full position, check offset only. 9 | */ 10 | describe('Trigger.Point', () => { 11 | beforeEach(() => { 12 | jest.useFakeTimers(); 13 | }); 14 | 15 | afterEach(() => { 16 | cleanup(); 17 | jest.useRealTimers(); 18 | }); 19 | 20 | class Demo extends React.Component { 21 | popup = (
POPUP
); 22 | 23 | render() { 24 | return ( 25 |
30 | 37 |
38 | 39 |
40 | ); 41 | } 42 | } 43 | 44 | async function trigger(container, eventName, data) { 45 | const pointRegion = container.querySelector('.point-region'); 46 | fireEvent(pointRegion, getMouseEvent(eventName, data)); 47 | 48 | // React scheduler will not hold when useEffect. We need repeat to tell that times go 49 | for (let i = 0; i < 10; i += 1) { 50 | await act(async () => { 51 | jest.runAllTimers(); 52 | await Promise.resolve(); 53 | }); 54 | } 55 | } 56 | 57 | it('onClick', async () => { 58 | const { container } = render(); 59 | await trigger(container, 'click', { clientX: 11, clientY: 28 }); 60 | 61 | const popup = document.querySelector('.rc-trigger-popup'); 62 | expect(popup).toHaveStyle({ left: '11px', top: '28px' }); 63 | }); 64 | 65 | it('hover', async () => { 66 | const { container } = render(); 67 | await trigger(container, 'mouseenter', { clientX: 10, clientY: 20 }); 68 | await trigger(container, 'mouseover', { clientX: 9, clientY: 3 }); 69 | 70 | const popup = document.querySelector('.rc-trigger-popup'); 71 | expect(popup).toHaveStyle({ left: '9px', top: '3px' }); 72 | }); 73 | 74 | describe('contextMenu', () => { 75 | it('basic', async () => { 76 | const { container } = render( 77 | , 78 | ); 79 | await trigger(container, 'contextmenu', { clientX: 10, clientY: 20 }); 80 | 81 | const popup = document.querySelector('.rc-trigger-popup'); 82 | expect(popup.style).toEqual( 83 | expect.objectContaining({ left: '10px', top: '20px' }), 84 | ); 85 | 86 | // Not trigger point update when close 87 | const clickEvent = {}; 88 | const pagePropDefine = { 89 | get: () => { 90 | throw new Error('should not read when close'); 91 | }, 92 | }; 93 | Object.defineProperties(clickEvent, { 94 | clientX: pagePropDefine, 95 | clientY: pagePropDefine, 96 | }); 97 | fireEvent( 98 | container.querySelector('.point-region'), 99 | getMouseEvent('click', clickEvent), 100 | ); 101 | 102 | expect(document.querySelector('.rc-trigger-popup-hidden')).toBeTruthy(); 103 | }); 104 | 105 | // https://github.com/ant-design/ant-design/issues/17043 106 | it('not prevent default', (done) => { 107 | (async function () { 108 | const { container } = render( 109 | , 110 | ); 111 | await trigger(container, 'contextmenu', { clientX: 10, clientY: 20 }); 112 | 113 | const popup = document.querySelector('.rc-trigger-popup'); 114 | expect(popup).toHaveStyle({ left: '10px', top: '20px' }); 115 | 116 | // Click to close 117 | fireEvent( 118 | document.querySelector('.rc-trigger-popup > *'), 119 | getMouseEvent('click', { 120 | preventDefault() { 121 | done.fail(); 122 | }, 123 | }), 124 | ); 125 | 126 | done(); 127 | })(); 128 | }); 129 | 130 | it('should hide popup when set alignPoint after scrolling', async () => { 131 | const { container } = render(); 132 | await trigger(container, 'contextmenu', { clientX: 10, clientY: 20 }); 133 | 134 | const popup = document.querySelector('.rc-trigger-popup'); 135 | expect(popup.style).toEqual( 136 | expect.objectContaining({ left: '10px', top: '20px' }), 137 | ); 138 | 139 | const scrollDiv = container.querySelector('.scroll'); 140 | fireEvent.scroll(scrollDiv); 141 | 142 | expect(document.querySelector('.rc-trigger-popup-hidden')).toBeTruthy(); 143 | }); 144 | }); 145 | 146 | describe('placement', () => { 147 | function testPlacement(name, builtinPlacements, afterAll) { 148 | it(name, async () => { 149 | const { container } = render( 150 | , 155 | ); 156 | await trigger(container, 'click', { clientX: 10, clientY: 20 }); 157 | 158 | const popup = document.querySelector('.rc-trigger-popup'); 159 | expect(popup.style).toEqual( 160 | expect.objectContaining({ left: '10px', top: '20px' }), 161 | ); 162 | 163 | if (afterAll) { 164 | afterAll(document.body); 165 | } 166 | }); 167 | } 168 | 169 | testPlacement('not hit', { 170 | right: { 171 | // This should not hit 172 | points: ['cl'], 173 | }, 174 | }); 175 | 176 | testPlacement( 177 | 'hit builtin', 178 | { 179 | left: { 180 | points: ['tl'], 181 | }, 182 | }, 183 | (wrapper) => { 184 | expect(wrapper.querySelector('div.rc-trigger-popup')).toHaveClass( 185 | 'rc-trigger-popup-placement-left', 186 | ); 187 | }, 188 | ); 189 | }); 190 | }); 191 | -------------------------------------------------------------------------------- /tests/shadow.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, fireEvent } from '@testing-library/react'; 2 | import { resetWarned } from '@rc-component/util/lib/warning'; 3 | import React from 'react'; 4 | import { createRoot } from 'react-dom/client'; 5 | import Trigger from '../src'; 6 | import { awaitFakeTimer } from './util'; 7 | 8 | describe('Trigger.Shadow', () => { 9 | beforeEach(() => { 10 | resetWarned(); 11 | jest.useFakeTimers(); 12 | }); 13 | 14 | afterEach(() => { 15 | jest.useRealTimers(); 16 | }); 17 | 18 | const Demo: React.FC = (props?: any) => ( 19 | <> 20 | } 23 | builtinPlacements={{ 24 | top: {}, 25 | }} 26 | popupPlacement="top" 27 | {...props} 28 | > 29 |

30 | 31 | 32 | {/* Placeholder element which not related with Trigger */} 33 |

34 | 35 | ); 36 | 37 | const renderShadow = (props?: any) => { 38 | const noRelatedSpan = document.createElement('span'); 39 | document.body.appendChild(noRelatedSpan); 40 | 41 | const host = document.createElement('div'); 42 | document.body.appendChild(host); 43 | 44 | const shadowRoot = host.attachShadow({ 45 | mode: 'open', 46 | delegatesFocus: false, 47 | }); 48 | const container = document.createElement('div'); 49 | shadowRoot.appendChild(container); 50 | 51 | act(() => { 52 | createRoot(container).render(); 53 | }); 54 | 55 | return shadowRoot; 56 | }; 57 | 58 | const renderMultiLevelShadow = (props?: any) => { 59 | const noRelatedSpan = document.createElement('span'); 60 | document.body.appendChild(noRelatedSpan); 61 | 62 | const wrapperHost = document.createElement('div'); 63 | const wrapperShadowRoot = wrapperHost.attachShadow({ 64 | mode: 'open', 65 | delegatesFocus: false, 66 | }); 67 | document.body.appendChild(wrapperHost); 68 | 69 | const host = document.createElement('div'); 70 | wrapperShadowRoot.appendChild(host); 71 | 72 | const shadowRoot = host.attachShadow({ 73 | mode: 'open', 74 | delegatesFocus: false, 75 | }); 76 | const container = document.createElement('div'); 77 | shadowRoot.appendChild(container); 78 | 79 | act(() => { 80 | createRoot(container).render(); 81 | }); 82 | 83 | return shadowRoot; 84 | }; 85 | 86 | it('popup not in the same shadow', async () => { 87 | const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 88 | const shadowRoot = renderShadow(); 89 | 90 | await awaitFakeTimer(); 91 | 92 | fireEvent.click(shadowRoot.querySelector('.target')); 93 | 94 | await awaitFakeTimer(); 95 | 96 | expect(errSpy).toHaveBeenCalledWith( 97 | `Warning: trigger element and popup element should in same shadow root.`, 98 | ); 99 | errSpy.mockRestore(); 100 | }); 101 | 102 | it('click in shadow should not close popup', async () => { 103 | const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 104 | const shadowRoot = renderShadow({ 105 | getPopupContainer: (item: HTMLElement) => item.parentElement, 106 | autoDestroy: true, 107 | }); 108 | 109 | await awaitFakeTimer(); 110 | 111 | // Click to show 112 | fireEvent.click(shadowRoot.querySelector('.target')); 113 | await awaitFakeTimer(); 114 | expect(shadowRoot.querySelector('.bamboo')).toBeTruthy(); 115 | 116 | // Click outside to hide 117 | fireEvent.mouseDown(document.body.firstChild); 118 | await awaitFakeTimer(); 119 | expect(shadowRoot.querySelector('.bamboo')).toBeFalsy(); 120 | 121 | // Click to show again 122 | fireEvent.click(shadowRoot.querySelector('.target')); 123 | await awaitFakeTimer(); 124 | expect(shadowRoot.querySelector('.bamboo')).toBeTruthy(); 125 | 126 | // Click in side shadow to hide 127 | fireEvent.mouseDown(shadowRoot.querySelector('.little')); 128 | await awaitFakeTimer(); 129 | expect(shadowRoot.querySelector('.bamboo')).toBeFalsy(); 130 | 131 | expect(errSpy).not.toHaveBeenCalled(); 132 | errSpy.mockRestore(); 133 | }); 134 | 135 | it('click on target in shadow should not close popup', async () => { 136 | const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 137 | const shadowRoot = renderShadow({ 138 | getPopupContainer: (item: HTMLElement) => item.parentElement, 139 | autoDestroy: true, 140 | }); 141 | 142 | await awaitFakeTimer(); 143 | 144 | // Click to show 145 | fireEvent.click(shadowRoot.querySelector('.target')); 146 | await awaitFakeTimer(); 147 | expect(shadowRoot.querySelector('.bamboo')).toBeTruthy(); 148 | 149 | // Click on target 150 | fireEvent.mouseDown(shadowRoot.querySelector('.bamboo')); 151 | await awaitFakeTimer(); 152 | expect(shadowRoot.querySelector('.bamboo')).toBeTruthy(); 153 | 154 | expect(errSpy).not.toHaveBeenCalled(); 155 | errSpy.mockRestore(); 156 | }); 157 | 158 | it('click on target with multilevel shadows should not close popup', async () => { 159 | const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 160 | const shadowRoot = renderMultiLevelShadow({ 161 | getPopupContainer: (item: HTMLElement) => item.parentElement, 162 | autoDestroy: true, 163 | }); 164 | 165 | await awaitFakeTimer(); 166 | 167 | // Click to show 168 | fireEvent.click(shadowRoot.querySelector('.target')); 169 | await awaitFakeTimer(); 170 | expect(shadowRoot.querySelector('.bamboo')).toBeTruthy(); 171 | 172 | // Click outside to hide 173 | fireEvent.mouseDown(document.body.firstChild); 174 | await awaitFakeTimer(); 175 | expect(shadowRoot.querySelector('.bamboo')).toBeFalsy(); 176 | 177 | // Click to show again 178 | fireEvent.click(shadowRoot.querySelector('.target')); 179 | await awaitFakeTimer(); 180 | expect(shadowRoot.querySelector('.bamboo')).toBeTruthy(); 181 | 182 | // Click in side shadow to hide 183 | fireEvent.mouseDown(shadowRoot.querySelector('.little')); 184 | await awaitFakeTimer(); 185 | expect(shadowRoot.querySelector('.bamboo')).toBeFalsy(); 186 | 187 | // Click to show again 188 | fireEvent.click(shadowRoot.querySelector('.target')); 189 | await awaitFakeTimer(); 190 | expect(shadowRoot.querySelector('.bamboo')).toBeTruthy(); 191 | 192 | // Click on target should not hide 193 | fireEvent.mouseDown(shadowRoot.querySelector('.bamboo')); 194 | await awaitFakeTimer(); 195 | expect(shadowRoot.querySelector('.bamboo')).toBeTruthy(); 196 | 197 | expect(errSpy).not.toHaveBeenCalled(); 198 | errSpy.mockRestore(); 199 | }); 200 | }); 201 | -------------------------------------------------------------------------------- /tests/arrow.test.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | 3 | import { act, cleanup, render } from '@testing-library/react'; 4 | import { 5 | spyElementPrototype, 6 | spyElementPrototypes, 7 | } from '@rc-component/util/lib/test/domHook'; 8 | import Trigger from '../src'; 9 | 10 | describe('Trigger.Arrow', () => { 11 | beforeAll(() => { 12 | spyElementPrototypes(HTMLElement, { 13 | offsetParent: { 14 | get: () => document.body, 15 | }, 16 | }); 17 | }); 18 | 19 | beforeEach(() => { 20 | jest.useFakeTimers(); 21 | }); 22 | 23 | afterEach(() => { 24 | cleanup(); 25 | jest.useRealTimers(); 26 | }); 27 | 28 | async function awaitFakeTimer() { 29 | for (let i = 0; i < 10; i += 1) { 30 | await act(async () => { 31 | jest.advanceTimersByTime(100); 32 | await Promise.resolve(); 33 | }); 34 | } 35 | } 36 | 37 | it('not show', () => { 38 | render( 39 | trigger} arrow> 40 |
41 | , 42 | ); 43 | }); 44 | 45 | describe('direction', () => { 46 | let divSpy; 47 | let windowSpy; 48 | 49 | beforeAll(() => { 50 | divSpy = spyElementPrototype( 51 | HTMLDivElement, 52 | 'getBoundingClientRect', 53 | () => ({ 54 | x: 200, 55 | y: 200, 56 | left: 200, 57 | top: 200, 58 | width: 100, 59 | height: 50, 60 | }), 61 | ); 62 | 63 | windowSpy = spyElementPrototypes(Window, { 64 | clientWidth: { 65 | get: () => 1000, 66 | }, 67 | clientHeight: { 68 | get: () => 1000, 69 | }, 70 | }); 71 | }); 72 | 73 | afterAll(() => { 74 | divSpy.mockRestore(); 75 | windowSpy.mockRestore(); 76 | }); 77 | 78 | function test(name, align, style) { 79 | it(name, async () => { 80 | render( 81 | trigger} 85 | arrow 86 | > 87 |
88 | , 89 | ); 90 | 91 | await awaitFakeTimer(); 92 | 93 | expect(document.querySelector('.rc-trigger-popup-arrow')).toHaveStyle( 94 | style, 95 | ); 96 | }); 97 | } 98 | 99 | // Top 100 | test( 101 | 'top', 102 | { 103 | points: ['bl', 'tl'], 104 | }, 105 | { 106 | bottom: 0, 107 | }, 108 | ); 109 | 110 | // Bottom 111 | test( 112 | 'bottom', 113 | { 114 | points: ['tc', 'bc'], 115 | }, 116 | { 117 | top: 0, 118 | }, 119 | ); 120 | 121 | // Left 122 | test( 123 | 'left', 124 | { 125 | points: ['cr', 'cl'], 126 | }, 127 | { 128 | right: 0, 129 | }, 130 | ); 131 | 132 | // Right 133 | test( 134 | 'right', 135 | { 136 | points: ['cl', 'cr'], 137 | }, 138 | { 139 | left: 0, 140 | }, 141 | ); 142 | 143 | it('not aligned', async () => { 144 | render( 145 | trigger} 152 | arrow 153 | > 154 |
155 | , 156 | ); 157 | 158 | await awaitFakeTimer(); 159 | 160 | // Not have other align style 161 | const { style } = document.querySelector('.rc-trigger-popup-arrow'); 162 | expect(style.position).toBeTruthy(); 163 | expect(style.left).toBeFalsy(); 164 | expect(style.right).toBeFalsy(); 165 | expect(style.top).toBeFalsy(); 166 | expect(style.bottom).toBeFalsy(); 167 | }); 168 | 169 | it('arrow classname', async () => { 170 | render( 171 | trigger} 178 | arrow={{ 179 | className: 'abc', 180 | }} 181 | > 182 |
183 | , 184 | ); 185 | 186 | await awaitFakeTimer(); 187 | 188 | const arrowDom = document.querySelector('.rc-trigger-popup-arrow'); 189 | expect(arrowDom.classList.contains('abc')).toBeTruthy(); 190 | }); 191 | 192 | it('arrow style', async () => { 193 | render( 194 | trigger} 201 | arrow={{ 202 | style: { 203 | color: 'red', 204 | backgroundColor: 'blue', 205 | }, 206 | }} 207 | > 208 |
209 | , 210 | ); 211 | 212 | await awaitFakeTimer(); 213 | 214 | const arrowDom = document.querySelector('.rc-trigger-popup-arrow'); 215 | expect(arrowDom).toHaveStyle({ 216 | color: 'red', 217 | backgroundColor: 'blue', 218 | }); 219 | }); 220 | 221 | it('arrow style should merge with align style', async () => { 222 | render( 223 | trigger} 230 | arrow={{ 231 | style: { 232 | color: 'red', 233 | backgroundColor: 'blue', 234 | }, 235 | }} 236 | > 237 |
238 | , 239 | ); 240 | 241 | await awaitFakeTimer(); 242 | 243 | const arrowDom = document.querySelector('.rc-trigger-popup-arrow'); 244 | // Should have both align style (left: 0) and custom style 245 | expect(arrowDom).toHaveStyle({ 246 | left: 0, 247 | color: 'red', 248 | backgroundColor: 'blue', 249 | }); 250 | }); 251 | }); 252 | 253 | it('content', async () => { 254 | render( 255 | trigger} 262 | arrow={{ 263 | content: , 264 | }} 265 | > 266 |
267 | , 268 | ); 269 | 270 | await awaitFakeTimer(); 271 | 272 | expect(document.querySelector('.my-content')).toBeTruthy(); 273 | }); 274 | }); 275 | -------------------------------------------------------------------------------- /docs/examples/body-overflow.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import Trigger from '@rc-component/trigger'; 3 | import React from 'react'; 4 | import { createPortal } from 'react-dom'; 5 | import '../../assets/index.less'; 6 | 7 | const PortalDemo = () => { 8 | return createPortal( 9 |
18 | PortalNode 19 |
, 20 | document.body, 21 | ); 22 | }; 23 | 24 | export default () => { 25 | const [open, setOpen] = React.useState(false); 26 | const [open1, setOpen1] = React.useState(false); 27 | const [open2, setOpen2] = React.useState(false); 28 | const [open3, setOpen3] = React.useState(false); 29 | return ( 30 | 31 |