├── .dumirc.ts ├── .editorconfig ├── .eslintrc.js ├── .fatherrc.js ├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── .prettierrc ├── HISTORY.md ├── LICENSE.md ├── README.md ├── docs ├── demo │ ├── follow.md │ ├── point.md │ └── simple.md ├── examples │ ├── follow.tsx │ ├── point.tsx │ └── simple.tsx └── index.md ├── index.js ├── jest.config.js ├── now.json ├── package.json ├── src ├── Align.tsx ├── hooks │ └── useBuffer.tsx ├── index.ts ├── interface.ts └── util.ts ├── tests ├── element.test.tsx ├── point.test.js ├── strict.test.tsx └── util.test.tsx └── tsconfig.json /.dumirc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'dumi'; 2 | 3 | export default defineConfig({ 4 | themeConfig: { 5 | name: 'Align', 6 | }, 7 | mfsu: false, 8 | }); -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const base = require('@umijs/fabric/dist/eslint'); 2 | 3 | module.exports = { 4 | ...base, 5 | rules: { 6 | ...base.rules, 7 | 'react/no-find-dom-node': 0, 8 | 'jsx-a11y/label-has-associated-control': 0, 9 | 'jsx-a11y/label-has-for': 0, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /.fatherrc.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'father'; 2 | 3 | export default defineConfig({ 4 | plugins: ['@rc-component/father-plugin'], 5 | }); -------------------------------------------------------------------------------- /.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: "@types/jest" 11 | versions: 12 | - 26.0.20 13 | - 26.0.21 14 | - 26.0.22 15 | - dependency-name: "@types/react" 16 | versions: 17 | - 17.0.0 18 | - 17.0.1 19 | - 17.0.2 20 | - 17.0.3 21 | - dependency-name: "@types/react-dom" 22 | versions: 23 | - 17.0.0 24 | - 17.0.1 25 | - 17.0.2 26 | - dependency-name: np 27 | versions: 28 | - 7.2.0 29 | - 7.3.0 30 | - 7.4.0 31 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | .build 22 | node_modules 23 | .cache 24 | dist 25 | assets/**/*.css 26 | build 27 | lib 28 | es 29 | coverage 30 | package-lock.json 31 | yarn.lock 32 | .dumi -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "proseWrap": "never", 5 | "printWidth": 100, 6 | "arrowParens": "avoid" 7 | } -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # History 2 | ---- 3 | 4 | ## 2.4.0 / 2018-06-04 5 | 6 | - support point align 7 | 8 | ## 2.3.4 / 2017-04-17 9 | 10 | - fix `createClass` and `PropTypes` warning. 11 | 12 | ## 2.3.0 / 2016-05-26 13 | 14 | - add forceAlign method 15 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-present yiminghe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rc-align 2 | --- 3 | 4 | React Align Component. Wrapper around https://github.com/yiminghe/dom-align. 5 | 6 | [![NPM version][npm-image]][npm-url] 7 | [![npm download][download-image]][download-url] 8 | [![build status][github-actions-image]][github-actions-url] 9 | [![Codecov][codecov-image]][codecov-url] 10 | [![bundle size][bundlephobia-image]][bundlephobia-url] 11 | [![dumi][dumi-image]][dumi-url] 12 | 13 | [npm-image]: http://img.shields.io/npm/v/rc-align.svg?style=flat-square 14 | [npm-url]: http://npmjs.org/package/rc-align 15 | [travis-image]: https://img.shields.io/travis/react-component/align/master?style=flat-square 16 | [travis-url]: https://travis-ci.com/react-component/align 17 | [github-actions-image]: https://github.com/react-component/align/workflows/CI/badge.svg 18 | [github-actions-url]: https://github.com/react-component/align/actions 19 | [codecov-image]: https://img.shields.io/codecov/c/github/react-component/align/master.svg?style=flat-square 20 | [codecov-url]: https://app.codecov.io/gh/react-component/align 21 | [david-url]: https://david-dm.org/react-component/align 22 | [david-image]: https://david-dm.org/react-component/align/status.svg?style=flat-square 23 | [david-dev-url]: https://david-dm.org/react-component/align?type=dev 24 | [david-dev-image]: https://david-dm.org/react-component/align/dev-status.svg?style=flat-square 25 | [download-image]: https://img.shields.io/npm/dm/rc-align.svg?style=flat-square 26 | [download-url]: https://npmjs.org/package/rc-align 27 | [bundlephobia-url]: https://bundlephobia.com/package/rc-align 28 | [bundlephobia-image]: https://badgen.net/bundlephobia/minzip/rc-align 29 | [dumi-url]: https://github.com/umijs/dumi 30 | [dumi-image]: https://img.shields.io/badge/docs%20by-dumi-blue?style=flat-square 31 | 32 | 33 | ## Development 34 | 35 | ``` 36 | npm install 37 | npm start 38 | ``` 39 | 40 | ## Example 41 | 42 | http://localhost:8100/examples/ 43 | 44 | online example: http://react-component.github.io/align/examples/ 45 | 46 | 47 | ## Feature 48 | 49 | * support ie8,ie8+,chrome,firefox,safari 50 | 51 | ### Keyboard 52 | 53 | 54 | 55 | ## install 56 | 57 | [![rc-align](https://nodei.co/npm/rc-align.png)](https://npmjs.org/package/rc-align) 58 | 59 | ## Usage 60 | 61 | ```js 62 | var Align = require('rc-align'); 63 | var ReactDOM = require('react-dom'); 64 | ReactDOM.render(
, container); 65 | ``` 66 | 67 | will align child with target when mounted or align is changed 68 | 69 | ## API 70 | 71 | ### props 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 102 | 103 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 |
nametypedefaultdescription
alignObjectsame with alignConfig from https://github.com/yiminghe/dom-align
onAlignfunction(source:HTMLElement, align:Object)called when align
target 98 | function():HTMLElement || 99 | { pageX: number, pageY: number } || 100 | { clientX: number, clientY: number } 101 | function(){return window;} 104 | a function which returned value or point is used for target from https://github.com/yiminghe/dom-align 105 |
monitorWindowResizeBooleanfalsewhether realign when window is resized
115 | 116 | 117 | ## License 118 | 119 | rc-align is released under the MIT license. 120 | -------------------------------------------------------------------------------- /docs/demo/follow.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Follow 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | -------------------------------------------------------------------------------- /docs/demo/point.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Point 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | -------------------------------------------------------------------------------- /docs/demo/simple.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Simple 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | -------------------------------------------------------------------------------- /docs/examples/follow.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Align from '../../src'; 3 | 4 | const Demo = () => { 5 | const [width, setWidth] = React.useState(100); 6 | const [height, setHeight] = React.useState(100); 7 | const [left, setLeft] = React.useState(100); 8 | const [top, setTop] = React.useState(100); 9 | const [visible, setVisible] = React.useState(true); 10 | const [svg, setSvg] = React.useState(false); 11 | 12 | const sharedStyle: React.CSSProperties = { 13 | width, 14 | height, 15 | position: 'absolute', 16 | left, 17 | top, 18 | display: visible ? 'flex' : 'none', 19 | }; 20 | 21 | return ( 22 |
23 | 34 | 42 | 50 | 51 |
58 | {svg ? ( 59 | 60 | 61 | 62 | ) : ( 63 |
73 | Content 74 |
75 | )} 76 | 77 | document.getElementById('content')} align={{ points: ['tc', 'bc'] }}> 78 |
84 | Popup 85 |
86 |
87 |
88 |
89 | ); 90 | }; 91 | 92 | export default Demo; 93 | -------------------------------------------------------------------------------- /docs/examples/point.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import type { RefAlign } from '../../src'; 3 | import Align from '../../src'; 4 | 5 | const align = { 6 | points: ['cc', 'cc'], 7 | }; 8 | 9 | class Demo extends Component { 10 | alignRef = React.createRef(); 11 | 12 | state = { 13 | point: null, 14 | }; 15 | 16 | onClick = ({ pageX, pageY }) => { 17 | this.setState({ point: { pageX, pageY } }); 18 | }; 19 | 20 | render() { 21 | return ( 22 |
23 |
27 | Click this region please : ) 28 |
29 | 30 | 31 |
40 | Align 41 |
42 |
43 |
44 | ); 45 | } 46 | } 47 | 48 | export default Demo; 49 | -------------------------------------------------------------------------------- /docs/examples/simple.tsx: -------------------------------------------------------------------------------- 1 | import Align, { type RefAlign } from 'rc-align'; 2 | import React, { Component } from 'react'; 3 | 4 | const allPoints = ['tl', 'tc', 'tr', 'cl', 'cc', 'cr', 'bl', 'bc', 'br']; 5 | 6 | interface TestState { 7 | monitor: boolean; 8 | random: boolean; 9 | disabled: boolean; 10 | randomWidth: number; 11 | align: any; 12 | sourceWidth: number; 13 | } 14 | 15 | class Test extends Component<{}, TestState> { 16 | state = { 17 | monitor: true, 18 | random: false, 19 | disabled: false, 20 | randomWidth: 100, 21 | align: { 22 | points: ['cc', 'cc'], 23 | }, 24 | sourceWidth: 50, 25 | }; 26 | 27 | id: NodeJS.Timer; 28 | $container: HTMLElement; 29 | $align: RefAlign; 30 | 31 | componentDidMount() { 32 | this.id = setInterval(() => { 33 | const { random } = this.state; 34 | if (random) { 35 | this.setState({ 36 | randomWidth: 60 + 40 * Math.random(), 37 | }); 38 | } 39 | }, 1000); 40 | } 41 | 42 | componentWillUnmount() { 43 | clearInterval(this.id); 44 | } 45 | 46 | getTarget = () => { 47 | if (!this.$container) { 48 | // parent ref not attached 49 | this.$container = document.getElementById('container'); 50 | } 51 | return this.$container; 52 | }; 53 | 54 | containerRef = ele => { 55 | this.$container = ele; 56 | }; 57 | 58 | alignRef = node => { 59 | this.$align = node; 60 | }; 61 | 62 | toggleMonitor = () => { 63 | this.setState(({ monitor }) => ({ 64 | monitor: !monitor, 65 | })); 66 | }; 67 | 68 | toggleRandom = () => { 69 | this.setState(({ random }) => ({ 70 | random: !random, 71 | })); 72 | }; 73 | 74 | toggleDisabled = () => { 75 | this.setState(({ disabled }) => ({ 76 | disabled: !disabled, 77 | })); 78 | }; 79 | 80 | randomAlign = () => { 81 | const randomPoints = []; 82 | randomPoints.push(allPoints[Math.floor(Math.random() * 100) % allPoints.length]); 83 | randomPoints.push(allPoints[Math.floor(Math.random() * 100) % allPoints.length]); 84 | this.setState({ 85 | align: { 86 | points: randomPoints, 87 | }, 88 | }); 89 | }; 90 | 91 | forceAlign = () => { 92 | this.$align.forceAlign(); 93 | }; 94 | 95 | toggleSourceSize = () => { 96 | this.setState({ 97 | // eslint-disable-next-line react/no-access-state-in-setstate 98 | sourceWidth: this.state.sourceWidth + 10, 99 | }); 100 | }; 101 | 102 | render() { 103 | const { random, randomWidth } = this.state; 104 | 105 | return ( 106 |
111 |

112 | 115 |     116 | 119 |     120 | 123 |     124 | 128 | 132 | 136 |

137 |
152 | 159 |
167 | 168 |
169 |
170 |
171 |
172 | ); 173 | } 174 | } 175 | 176 | export default Test; 177 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hero: 3 | title: rc-align 4 | description: align ui component for react 5 | --- 6 | 7 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // export this package's api 4 | import Align from './src/'; 5 | export default Align; 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | }; -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "name": "rc-align", 4 | "builds": [ 5 | { 6 | "src": "package.json", 7 | "use": "@now/static-build", 8 | "config": { "distDir": ".doc" } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rc-align", 3 | "version": "4.0.15", 4 | "description": "align ui component for react", 5 | "keywords": [ 6 | "react", 7 | "react-component", 8 | "react-align", 9 | "align" 10 | ], 11 | "homepage": "http://github.com/react-component/align", 12 | "bugs": { 13 | "url": "http://github.com/react-component/align/issues" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git@github.com:react-component/align.git" 18 | }, 19 | "license": "MIT", 20 | "author": "", 21 | "main": "./lib/index", 22 | "module": "./es/index", 23 | "files": [ 24 | "lib", 25 | "es" 26 | ], 27 | "scripts": { 28 | "build": "dumi build", 29 | "compile": "father build", 30 | "coverage": "father test --coverage", 31 | "lint": "eslint src/ docs/ --ext .tsx,.ts,.jsx,.js", 32 | "now-build": "npm run build", 33 | "prepublishOnly": "npm run compile && np --yolo --no-publish", 34 | "start": "dumi dev", 35 | "test": "rc-test", 36 | "tsc": "tsc --noEmit" 37 | }, 38 | "dependencies": { 39 | "@babel/runtime": "^7.10.1", 40 | "classnames": "2.x", 41 | "dom-align": "^1.7.0", 42 | "rc-util": "^5.26.0", 43 | "resize-observer-polyfill": "^1.5.1" 44 | }, 45 | "devDependencies": { 46 | "@rc-component/father-plugin": "^1.0.0", 47 | "@types/jest": "^24.0.18", 48 | "@types/react": "^18.0.0", 49 | "@types/react-dom": "^18.0.0", 50 | "@types/warning": "^3.0.0", 51 | "cross-env": "^7.0.3", 52 | "dumi": "^2.0.15", 53 | "eslint": "^8.56.0", 54 | "eslint-plugin-unicorn": "^55.0.0", 55 | "father": "^4.0.0", 56 | "np": "^5.0.3", 57 | "rc-test": "^7.0.14", 58 | "react": "^18.0.0", 59 | "react-dom": "^18.0.0", 60 | "typescript": "^4.0.3" 61 | }, 62 | "peerDependencies": { 63 | "react": ">=16.9.0", 64 | "react-dom": ">=16.9.0" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Align.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Removed props: 3 | * - childrenProps 4 | */ 5 | 6 | import { alignElement, alignPoint } from 'dom-align'; 7 | import isEqual from 'rc-util/lib/isEqual'; 8 | import addEventListener from 'rc-util/lib/Dom/addEventListener'; 9 | import isVisible from 'rc-util/lib/Dom/isVisible'; 10 | import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect'; 11 | import { composeRef } from 'rc-util/lib/ref'; 12 | import React from 'react'; 13 | 14 | import useBuffer from './hooks/useBuffer'; 15 | import type { AlignResult, AlignType, TargetPoint, TargetType } from './interface'; 16 | import { isSamePoint, monitorResize, restoreFocus } from './util'; 17 | 18 | type OnAlign = (source: HTMLElement, result: AlignResult) => void; 19 | 20 | export interface AlignProps { 21 | align: AlignType; 22 | target: TargetType; 23 | onAlign?: OnAlign; 24 | monitorBufferTime?: number; 25 | monitorWindowResize?: boolean; 26 | disabled?: boolean; 27 | children: React.ReactElement; 28 | } 29 | 30 | export interface RefAlign { 31 | forceAlign: () => void; 32 | } 33 | 34 | function getElement(func: TargetType) { 35 | if (typeof func !== 'function') return null; 36 | return func(); 37 | } 38 | 39 | function getPoint(point: TargetType) { 40 | if (typeof point !== 'object' || !point) return null; 41 | return point; 42 | } 43 | 44 | const Align: React.ForwardRefRenderFunction = ( 45 | { children, disabled, target, align, onAlign, monitorWindowResize, monitorBufferTime = 0 }, 46 | ref, 47 | ) => { 48 | const cacheRef = React.useRef<{ element?: HTMLElement; point?: TargetPoint; align?: AlignType }>( 49 | {}, 50 | ); 51 | 52 | /** Popup node ref */ 53 | const nodeRef = React.useRef(); 54 | let childNode = React.Children.only(children); 55 | 56 | // ===================== Align ====================== 57 | // We save the props here to avoid closure makes props ood 58 | const forceAlignPropsRef = React.useRef<{ 59 | disabled?: boolean; 60 | target?: TargetType; 61 | align?: AlignType; 62 | onAlign?: OnAlign; 63 | }>({}); 64 | forceAlignPropsRef.current.disabled = disabled; 65 | forceAlignPropsRef.current.target = target; 66 | forceAlignPropsRef.current.align = align; 67 | forceAlignPropsRef.current.onAlign = onAlign; 68 | 69 | const [forceAlign, cancelForceAlign] = useBuffer(() => { 70 | const { 71 | disabled: latestDisabled, 72 | target: latestTarget, 73 | align: latestAlign, 74 | onAlign: latestOnAlign, 75 | } = forceAlignPropsRef.current; 76 | 77 | const source = nodeRef.current; 78 | 79 | if (!latestDisabled && latestTarget && source) { 80 | let result: AlignResult; 81 | const element = getElement(latestTarget); 82 | const point = getPoint(latestTarget); 83 | 84 | cacheRef.current.element = element; 85 | cacheRef.current.point = point; 86 | cacheRef.current.align = latestAlign; 87 | 88 | // IE lose focus after element realign 89 | // We should record activeElement and restore later 90 | const { activeElement } = document; 91 | 92 | // We only align when element is visible 93 | if (element && isVisible(element)) { 94 | result = alignElement(source, element, latestAlign); 95 | } else if (point) { 96 | result = alignPoint(source, point, latestAlign); 97 | } 98 | 99 | restoreFocus(activeElement, source); 100 | 101 | if (latestOnAlign && result) { 102 | latestOnAlign(source, result); 103 | } 104 | 105 | return true; 106 | } 107 | 108 | return false; 109 | }, monitorBufferTime); 110 | 111 | // ===================== Effect ===================== 112 | // Handle props change 113 | const [element, setElement] = React.useState(); 114 | const [point, setPoint] = React.useState(); 115 | 116 | useLayoutEffect(() => { 117 | setElement(getElement(target)); 118 | setPoint(getPoint(target)); 119 | }); 120 | 121 | React.useEffect(() => { 122 | if ( 123 | cacheRef.current.element !== element || 124 | !isSamePoint(cacheRef.current.point, point) || 125 | !isEqual(cacheRef.current.align, align) 126 | ) { 127 | forceAlign(); 128 | } 129 | }); 130 | 131 | // Watch popup element resize 132 | React.useEffect(() => { 133 | const cancelFn = monitorResize(nodeRef.current, forceAlign); 134 | return cancelFn; 135 | }, [nodeRef.current]); 136 | 137 | // Watch target element resize 138 | React.useEffect(() => { 139 | const cancelFn = monitorResize(element, forceAlign); 140 | return cancelFn; 141 | }, [element]); 142 | 143 | // Listen for disabled change 144 | React.useEffect(() => { 145 | if (!disabled) { 146 | forceAlign(); 147 | } else { 148 | cancelForceAlign(); 149 | } 150 | }, [disabled]); 151 | 152 | // Listen for window resize 153 | React.useEffect(() => { 154 | if (monitorWindowResize) { 155 | const cancelFn = addEventListener(window, 'resize', forceAlign); 156 | 157 | return cancelFn.remove; 158 | } 159 | }, [monitorWindowResize]); 160 | 161 | // Clear all if unmount 162 | React.useEffect( 163 | () => () => { 164 | cancelForceAlign(); 165 | }, 166 | [], 167 | ); 168 | 169 | // ====================== Ref ======================= 170 | React.useImperativeHandle(ref, () => ({ 171 | forceAlign: () => forceAlign(true), 172 | })); 173 | 174 | // ===================== Render ===================== 175 | if (React.isValidElement(childNode)) { 176 | childNode = React.cloneElement(childNode, { 177 | ref: composeRef((childNode as any).ref, nodeRef), 178 | }); 179 | } 180 | 181 | return childNode; 182 | }; 183 | 184 | const RcAlign = React.forwardRef(Align); 185 | RcAlign.displayName = 'Align'; 186 | 187 | export default RcAlign; 188 | -------------------------------------------------------------------------------- /src/hooks/useBuffer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default (callback: (force?: boolean) => boolean, buffer: number) => { 4 | const calledRef = React.useRef(false); 5 | const timeoutRef = React.useRef(null); 6 | 7 | function cancelTrigger() { 8 | window.clearTimeout(timeoutRef.current); 9 | } 10 | 11 | function trigger(force?: boolean) { 12 | cancelTrigger(); 13 | 14 | if (!calledRef.current || force === true) { 15 | if (callback(force) === false) { 16 | // Not delay since callback cancelled self 17 | return; 18 | } 19 | 20 | calledRef.current = true; 21 | timeoutRef.current = window.setTimeout(() => { 22 | calledRef.current = false; 23 | }, buffer); 24 | } else { 25 | timeoutRef.current = window.setTimeout(() => { 26 | calledRef.current = false; 27 | trigger(); 28 | }, buffer); 29 | } 30 | } 31 | 32 | return [ 33 | trigger, 34 | () => { 35 | calledRef.current = false; 36 | cancelTrigger(); 37 | }, 38 | ]; 39 | }; 40 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // export this package's api 2 | import Align from './Align'; 3 | 4 | export type { RefAlign } from './Align'; 5 | 6 | export default Align; 7 | -------------------------------------------------------------------------------- /src/interface.ts: -------------------------------------------------------------------------------- 1 | /** Two char of 't' 'b' 'c' 'l' 'r'. Example: 'lt' */ 2 | export type AlignPoint = string; 3 | 4 | export interface AlignType { 5 | /** 6 | * move point of source node to align with point of target node. 7 | * Such as ['tr','cc'], align top right point of source node with center point of target node. 8 | * Point can be 't'(top), 'b'(bottom), 'c'(center), 'l'(left), 'r'(right) */ 9 | points?: AlignPoint[]; 10 | /** 11 | * offset source node by offset[0] in x and offset[1] in y. 12 | * If offset contains percentage string value, it is relative to sourceNode region. 13 | */ 14 | offset?: number[]; 15 | /** 16 | * offset target node by offset[0] in x and offset[1] in y. 17 | * If targetOffset contains percentage string value, it is relative to targetNode region. 18 | */ 19 | targetOffset?: number[]; 20 | /** 21 | * If adjustX field is true, will adjust source node in x direction if source node is invisible. 22 | * If adjustY field is true, will adjust source node in y direction if source node is invisible. 23 | */ 24 | overflow?: { 25 | adjustX?: boolean | number; 26 | adjustY?: boolean | number; 27 | }; 28 | /** 29 | * Whether use css right instead of left to position 30 | */ 31 | useCssRight?: boolean; 32 | /** 33 | * Whether use css bottom instead of top to position 34 | */ 35 | useCssBottom?: boolean; 36 | /** 37 | * Whether use css transform instead of left/top/right/bottom to position if browser supports. 38 | * Defaults to false. 39 | */ 40 | useCssTransform?: boolean; 41 | } 42 | 43 | export interface AlignResult { 44 | points: AlignPoint[]; 45 | offset: number[]; 46 | targetOffset: number[]; 47 | overflow: { 48 | adjustX: boolean | number; 49 | adjustY: boolean | number; 50 | }; 51 | } 52 | 53 | export interface TargetPoint { 54 | clientX?: number; 55 | clientY?: number; 56 | pageX?: number; 57 | pageY?: number; 58 | } 59 | 60 | export type TargetType = (() => HTMLElement) | TargetPoint; 61 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import ResizeObserver from 'resize-observer-polyfill'; 2 | import contains from 'rc-util/lib/Dom/contains'; 3 | import type { TargetPoint } from './interface'; 4 | 5 | export function isSamePoint(prev: TargetPoint, next: TargetPoint) { 6 | if (prev === next) return true; 7 | if (!prev || !next) return false; 8 | 9 | if ('pageX' in next && 'pageY' in next) { 10 | return prev.pageX === next.pageX && prev.pageY === next.pageY; 11 | } 12 | 13 | if ('clientX' in next && 'clientY' in next) { 14 | return prev.clientX === next.clientX && prev.clientY === next.clientY; 15 | } 16 | 17 | return false; 18 | } 19 | 20 | export function restoreFocus(activeElement, container) { 21 | // Focus back if is in the container 22 | if ( 23 | activeElement !== document.activeElement && 24 | contains(container, activeElement) && 25 | typeof activeElement.focus === 'function' 26 | ) { 27 | activeElement.focus(); 28 | } 29 | } 30 | 31 | export function monitorResize(element: HTMLElement, callback: Function) { 32 | let prevWidth: number = null; 33 | let prevHeight: number = null; 34 | 35 | function onResize([{ target }]: ResizeObserverEntry[]) { 36 | if (!document.documentElement.contains(target)) return; 37 | const { width, height } = target.getBoundingClientRect(); 38 | const fixedWidth = Math.floor(width); 39 | const fixedHeight = Math.floor(height); 40 | 41 | if (prevWidth !== fixedWidth || prevHeight !== fixedHeight) { 42 | // https://webkit.org/blog/9997/resizeobserver-in-webkit/ 43 | Promise.resolve().then(() => { 44 | callback({ width: fixedWidth, height: fixedHeight }); 45 | }); 46 | } 47 | 48 | prevWidth = fixedWidth; 49 | prevHeight = fixedHeight; 50 | } 51 | 52 | const resizeObserver = new ResizeObserver(onResize); 53 | if (element) { 54 | resizeObserver.observe(element); 55 | } 56 | 57 | return () => { 58 | resizeObserver.disconnect(); 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /tests/element.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable class-methods-use-this */ 2 | import { render } from '@testing-library/react'; 3 | import { spyElementPrototype } from 'rc-util/lib/test/domHook'; 4 | import React from 'react'; 5 | import { renderToString } from 'react-dom/server'; 6 | import Align from '../src'; 7 | 8 | describe('element align', () => { 9 | beforeAll(() => { 10 | spyElementPrototype(HTMLElement, 'offsetParent', { 11 | get: () => ({}), 12 | }); 13 | }); 14 | 15 | beforeEach(() => { 16 | jest.useFakeTimers(); 17 | }); 18 | 19 | afterEach(() => { 20 | jest.clearAllTimers(); 21 | jest.useRealTimers(); 22 | }); 23 | 24 | const align = { 25 | points: ['bc', 'tc'], 26 | }; 27 | 28 | class Test extends React.Component { 29 | $target: any; 30 | 31 | getTarget = () => this.$target; 32 | 33 | targetRef = ele => { 34 | this.$target = ele; 35 | }; 36 | 37 | render() { 38 | return ( 39 |
40 |
41 | target 42 |
43 | 44 |
52 | source 53 |
54 |
55 |
56 | ); 57 | } 58 | } 59 | 60 | it('resize', () => { 61 | const onAlign = jest.fn(); 62 | 63 | const { unmount, rerender } = render(); 64 | expect(onAlign).toHaveBeenCalled(); 65 | 66 | // Window resize 67 | onAlign.mockReset(); 68 | window.dispatchEvent(new Event('resize')); 69 | jest.runAllTimers(); 70 | expect(onAlign).toHaveBeenCalled(); 71 | 72 | // Not listen resize 73 | onAlign.mockReset(); 74 | rerender(); 75 | window.dispatchEvent(new Event('resize')); 76 | jest.runAllTimers(); 77 | expect(onAlign).not.toHaveBeenCalled(); 78 | 79 | // Remove should not crash 80 | rerender(); 81 | unmount(); 82 | }); 83 | 84 | it('disabled should trigger align', () => { 85 | const onAlign = jest.fn(); 86 | 87 | const { rerender } = render(); 88 | expect(onAlign).not.toHaveBeenCalled(); 89 | 90 | rerender(); 91 | jest.runAllTimers(); 92 | expect(onAlign).toHaveBeenCalled(); 93 | }); 94 | 95 | // https://github.com/ant-design/ant-design/issues/31717 96 | it('changing align should trigger onAlign', () => { 97 | const onAlign = jest.fn(); 98 | const { rerender } = render(); 99 | expect(onAlign).toHaveBeenCalledTimes(1); 100 | expect(onAlign).toHaveBeenLastCalledWith( 101 | expect.any(HTMLElement), 102 | expect.objectContaining({ points: ['cc', 'cc'] }), 103 | ); 104 | // wrapper.setProps({ align: { points: ['cc', 'tl'] } }); 105 | rerender(); 106 | jest.runAllTimers(); 107 | expect(onAlign).toHaveBeenCalledTimes(2); 108 | expect(onAlign).toHaveBeenLastCalledWith( 109 | expect.any(HTMLElement), 110 | expect.objectContaining({ points: ['cc', 'tl'] }), 111 | ); 112 | }); 113 | 114 | it('should switch to the correct align callback after starting the timers', () => { 115 | // This test case is tricky. An error occurs if the following things happen 116 | // exactly in this order: 117 | // * Render with `onAlign1`. 118 | // * The callback in useBuffer is queued using setTimeout, to trigger after 119 | // `monitorBufferTime` ms (which even when it's set to 0 is queued and 120 | // not synchronously executed). 121 | // * The onAlign prop is changed to `onAlign2`. 122 | // * The callback from useBuffer is called. The now correct onAlign 123 | // callback would be `onAlign2`, and `onAlign1` should not be called. 124 | // This changing of the prop in between a 0 ms timeout is extremely rare. 125 | // It does however occur more often in real-world applications with 126 | // react-component/trigger, when its requestAnimationFrame and this timeout 127 | // race against each other. 128 | 129 | const onAlign1 = jest.fn(); 130 | const onAlign2 = jest.fn(); 131 | 132 | const { rerender } = render(); 133 | 134 | // Make sure the initial render's call to onAlign does not matter. 135 | onAlign1.mockReset(); 136 | onAlign2.mockReset(); 137 | 138 | // Re-render the component with the new callback. Expect from here on all 139 | // callbacks to call the new onAlign2. 140 | rerender(); 141 | 142 | // Now the timeout is executed, and we expect the onAlign2 callback to 143 | // receive the call, not onAlign1. 144 | jest.runAllTimers(); 145 | 146 | expect(onAlign1).not.toHaveBeenCalled(); 147 | expect(onAlign2).toHaveBeenCalled(); 148 | }); 149 | 150 | it('SSR no break', () => { 151 | const str = renderToString( 152 | { 155 | throw new Error('Not Call In Render'); 156 | }} 157 | />, 158 | ); 159 | expect(str).toBeTruthy(); 160 | }); 161 | }); 162 | /* eslint-enable */ 163 | -------------------------------------------------------------------------------- /tests/point.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable class-methods-use-this */ 2 | import { render } from '@testing-library/react'; 3 | import React from 'react'; 4 | import Align from '../src'; 5 | 6 | describe('point align', () => { 7 | function createAlign(props) { 8 | return ( 9 | 10 |
11 | 12 | ); 13 | } 14 | 15 | it('not pass point', () => { 16 | const onAlign = jest.fn(); 17 | 18 | render( 19 | createAlign({ 20 | align: { points: ['cc'] }, 21 | target: null, 22 | onAlign, 23 | }), 24 | ); 25 | 26 | expect(onAlign).not.toHaveBeenCalled(); 27 | }); 28 | 29 | it('pass point', () => { 30 | jest.useFakeTimers(); 31 | const onAlign = jest.fn(); 32 | 33 | const sharedProps = { 34 | align: { points: ['tc'] }, 35 | target: null, 36 | onAlign, 37 | }; 38 | 39 | const { rerender } = render(createAlign(sharedProps)); 40 | 41 | expect(onAlign).not.toHaveBeenCalled(); 42 | 43 | rerender( 44 | createAlign({ 45 | ...sharedProps, 46 | target: { pageX: 1128, pageY: 903 }, 47 | }), 48 | ); 49 | 50 | jest.runAllTimers(); 51 | expect(onAlign).toHaveBeenCalled(); 52 | 53 | jest.useRealTimers(); 54 | }); 55 | }); 56 | /* eslint-enable */ 57 | -------------------------------------------------------------------------------- /tests/strict.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable class-methods-use-this */ 2 | import { act, render } from '@testing-library/react'; 3 | import { spyElementPrototype } from 'rc-util/lib/test/domHook'; 4 | import React from 'react'; 5 | import Align from '../src'; 6 | 7 | (global as any).watchCnt = 0; 8 | 9 | jest.mock('../src/util', () => { 10 | const originUtil = jest.requireActual('../src/util'); 11 | 12 | return { 13 | ...originUtil, 14 | monitorResize: (...args: any[]) => { 15 | (global as any).watchCnt += 1; 16 | const cancelFn = originUtil.monitorResize(...args); 17 | 18 | return () => { 19 | (global as any).watchCnt -= 1; 20 | cancelFn(); 21 | }; 22 | }, 23 | }; 24 | }); 25 | 26 | describe('element align', () => { 27 | beforeAll(() => { 28 | spyElementPrototype(HTMLElement, 'offsetParent', { 29 | get: () => ({}), 30 | }); 31 | }); 32 | 33 | beforeEach(() => { 34 | jest.useFakeTimers(); 35 | }); 36 | 37 | afterEach(() => { 38 | jest.clearAllTimers(); 39 | jest.useRealTimers(); 40 | }); 41 | 42 | it('StrictMode should keep resize observer', () => { 43 | const Demo = () => { 44 | const targetRef = React.useRef(null); 45 | 46 | return ( 47 | <> 48 |
49 | targetRef.current} align={{ points: ['bc', 'tc'] }}> 50 |
57 | 58 | 59 | ); 60 | }; 61 | 62 | const { unmount } = render( 63 | 64 | 65 | , 66 | ); 67 | 68 | act(() => { 69 | jest.runAllTimers(); 70 | }); 71 | 72 | expect((global as any).watchCnt).toBeGreaterThan(0); 73 | 74 | unmount(); 75 | expect((global as any).watchCnt).toEqual(0); 76 | }); 77 | }); 78 | /* eslint-enable */ 79 | -------------------------------------------------------------------------------- /tests/util.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-this-alias */ 2 | import 'resize-observer-polyfill'; 3 | import { isSamePoint, monitorResize } from '../src/util'; 4 | 5 | let observer: any; 6 | 7 | 8 | jest.mock('resize-observer-polyfill', () => { 9 | return class ResizeObserverMock { 10 | onResize: any; 11 | element: any; 12 | 13 | constructor(onResize) { 14 | this.onResize = onResize; 15 | observer = this; 16 | } 17 | 18 | observe(element) { 19 | this.element = element; 20 | } 21 | 22 | disconnect() { 23 | this.element = null; 24 | this.onResize = null; 25 | } 26 | 27 | triggerResize() { 28 | this.onResize([{ target: this.element }]); 29 | } 30 | }; 31 | }); 32 | 33 | describe('util', () => { 34 | describe('isSamePoint', () => { 35 | it('by page', () => { 36 | expect( 37 | isSamePoint( 38 | { pageX: 1, pageY: 2, clientX: 3, clientY: 4 }, 39 | { pageX: 1, pageY: 2, clientX: 1, clientY: 5 }, 40 | ), 41 | ).toBeTruthy(); 42 | expect( 43 | isSamePoint( 44 | { pageX: 1, pageY: 2, clientX: 3, clientY: 4 }, 45 | { pageX: 5, pageY: 6, clientX: 3, clientY: 4 }, 46 | ), 47 | ).toBeFalsy(); 48 | }); 49 | 50 | it('by client', () => { 51 | expect( 52 | isSamePoint( 53 | { pageX: 0, pageY: 2, clientX: 3, clientY: 4 }, 54 | { pageY: 2, clientX: 3, clientY: 4 }, 55 | ), 56 | ).toBeTruthy(); 57 | expect( 58 | isSamePoint({ pageX: 0, pageY: 2, clientX: 3, clientY: 4 }, { clientX: 5, clientY: 4 }), 59 | ).toBeFalsy(); 60 | }); 61 | 62 | it('null should be false', () => { 63 | expect(isSamePoint({ pageX: 0, pageY: 2, clientX: 3, clientY: 4 }, null)).toBeFalsy(); 64 | expect(isSamePoint(null, { pageX: 0, pageY: 2, clientX: 3, clientY: 4 })).toBeFalsy(); 65 | }); 66 | it('2 empty should be false', () => { 67 | expect(isSamePoint({}, {})).toBeFalsy(); 68 | }); 69 | }); 70 | 71 | describe('monitorResize', () => { 72 | let element; 73 | 74 | beforeEach(() => { 75 | element = document.createElement('div'); 76 | element.getBoundingClientRect = jest.fn().mockReturnValueOnce({ 77 | width: 100, 78 | height: 100, 79 | }); 80 | document.body.appendChild(element); 81 | jest.useFakeTimers(); 82 | (global as any).requestAnimationFrame = fn => { 83 | setTimeout(fn, 16); 84 | }; 85 | }); 86 | 87 | afterEach(() => { 88 | if (element) element.remove(); 89 | jest.useRealTimers(); 90 | }); 91 | 92 | it('should defer callback to next frame', async () => { 93 | const callback = jest.fn(); 94 | monitorResize(element, callback); 95 | observer.triggerResize(); 96 | jest.runAllTimers(); 97 | await Promise.resolve(); 98 | expect(callback).toHaveBeenCalled(); 99 | }); 100 | 101 | it('should skip calling if target is removed already', () => { 102 | const callback = jest.fn(); 103 | monitorResize(element, callback); 104 | element.remove(); 105 | observer.triggerResize(); 106 | jest.runAllTimers(); 107 | expect(callback).not.toHaveBeenCalled(); 108 | }); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /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 | "paths": { 11 | "@/*": [ 12 | "src/*" 13 | ], 14 | "@@/*": [ 15 | ".dumi/tmp/*" 16 | ], 17 | "rc-align": [ 18 | "src/index.ts" 19 | ] 20 | } 21 | }, 22 | "include": [".dumi/**/*", ".dumirc.ts", "**/*.ts", "**/*.tsx"] 23 | } 24 | --------------------------------------------------------------------------------