├── .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 | [](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 | name |
77 | type |
78 | default |
79 | description |
80 |
81 |
82 |
83 |
84 | align |
85 | Object |
86 | |
87 | same with alignConfig from https://github.com/yiminghe/dom-align |
88 |
89 |
90 | onAlign |
91 | function(source:HTMLElement, align:Object) |
92 | |
93 | called when align |
94 |
95 |
96 | target |
97 |
98 | function():HTMLElement ||
99 | { pageX: number, pageY: number } ||
100 | { clientX: number, clientY: number }
101 | |
102 | function(){return window;} |
103 |
104 | a function which returned value or point is used for target from https://github.com/yiminghe/dom-align
105 | |
106 |
107 |
108 | monitorWindowResize |
109 | Boolean |
110 | false |
111 | whether realign when window is resized |
112 |
113 |
114 |
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 |
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 |
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 |
--------------------------------------------------------------------------------