├── .gitignore
├── .babelrc
├── .eslintrc
├── .editorconfig
├── demo
├── index.js
├── Hint.js
├── BadDropdown.js
├── Dropdown.js
├── BadHint.js
├── index.html
└── Demo.js
├── .travis.yml
├── LICENSE
├── src
├── Portal.js
└── RelativePortal.js
├── package.json
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | .cache
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["es2015", "stage-1", "react"]
3 | }
4 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "airbnb",
3 | "parser": "babel-eslint",
4 | }
5 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | end_of_line = lf
6 | insert_final_newline = true
7 | indent_size = 2
8 |
--------------------------------------------------------------------------------
/demo/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import Demo from './Demo';
4 |
5 | ReactDOM.render(, document.getElementById('app'));
6 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - '10'
4 |
5 | before_deploy:
6 | - npm run build-demo
7 |
8 | deploy:
9 | provider: pages
10 | local-dir: demo/dist
11 | skip-cleanup: true
12 | github-token: $GITHUB_TOKEN # Set in the settings page of your repository, as a secure variable
13 | keep-history: true
14 | on:
15 | branch: master
16 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 SmartProgress
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/demo/Hint.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import RelativePortal from '../src/RelativePortal';
4 |
5 | const styles = {
6 | container: {
7 | display: 'inline-block',
8 | },
9 | icon: {
10 | display: 'inline-block',
11 | textAlign: 'center',
12 | lineHeight: '16px',
13 | width: 16,
14 | height: 16,
15 | backgroundColor: '#fc0',
16 | borderRadius: 8,
17 | cursor: 'help',
18 | },
19 | tooltip: {
20 | padding: 5,
21 | backgroundColor: '#fc0',
22 | },
23 | };
24 |
25 | export default class Hint extends React.Component {
26 | static propTypes = {
27 | children: PropTypes.any,
28 | };
29 |
30 | state = {
31 | hover: false,
32 | };
33 |
34 | render() {
35 | const { children } = this.props;
36 | const { hover } = this.state;
37 |
38 | return (
39 |
40 |
this.setState({ hover: true })}
43 | onMouseOut={() => this.setState({ hover: false })}
44 | >
45 | ?
46 |
47 |
48 | {hover && {children}
}
49 |
50 |
51 | );
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/demo/BadDropdown.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | const styles = {
5 | container: {
6 | position: 'relative',
7 | display: 'inline-block',
8 | },
9 | dropdownW: {
10 | position: 'absolute',
11 | marginTop: 5,
12 | },
13 | dropdown: {
14 | width: 120,
15 | padding: 5,
16 | backgroundColor: '#FFF',
17 | boxShadow: '0 1px 3px rgba(0, 0, 0, 0.2)',
18 | },
19 | };
20 |
21 | export default class Hint extends React.Component {
22 | static propTypes = {
23 | children: PropTypes.any,
24 | position: PropTypes.oneOf(['left', 'right']),
25 | };
26 |
27 | state = {
28 | show: false,
29 | };
30 |
31 | render() {
32 | const { children, position = 'left' } = this.props;
33 | const { show } = this.state;
34 | const dropdownStyle = position === 'left' ? { left: 0 } : { right: 0 };
35 |
36 | return (
37 |
38 |
43 |
44 | {show && (
45 |
48 | )}
49 |
50 | );
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/demo/Dropdown.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import RelativePortal from '../src/RelativePortal';
4 |
5 | const styles = {
6 | container: {
7 | display: 'inline-block',
8 | },
9 | dropdown: {
10 | padding: 5,
11 | backgroundColor: '#FFF',
12 | boxShadow: '0 1px 3px rgba(0, 0, 0, 0.2)',
13 | },
14 | };
15 |
16 | export default class Hint extends React.Component {
17 | static propTypes = {
18 | children: PropTypes.any,
19 | position: PropTypes.oneOf(['left', 'right']),
20 | };
21 |
22 | state = {
23 | show: false,
24 | };
25 |
26 | render() {
27 | const { children, position = 'left' } = this.props;
28 | const { show } = this.state;
29 | const portalProps = position === 'left' ? {} : { right: 0 };
30 |
31 | return (
32 |
33 |
38 |
39 |
this.setState({ show: false }) : null}
43 | {...portalProps}
44 | >
45 | {show && {children}
}
46 |
47 |
48 | );
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/demo/BadHint.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | const styles = {
5 | container: {
6 | display: 'inline-block',
7 | },
8 | icon: {
9 | display: 'inline-block',
10 | textAlign: 'center',
11 | lineHeight: '16px',
12 | width: 16,
13 | height: 16,
14 | backgroundColor: '#fc0',
15 | borderRadius: 8,
16 | cursor: 'help',
17 | },
18 | tooltipW: {
19 | position: 'absolute',
20 | marginLeft: 5,
21 | },
22 | tooltip: {
23 | width: 120,
24 | padding: 5,
25 | backgroundColor: '#fc0',
26 | },
27 | };
28 |
29 | export default class BadHint extends React.Component {
30 | static propTypes = {
31 | children: PropTypes.any,
32 | };
33 |
34 | state = {
35 | hover: false,
36 | };
37 |
38 | render() {
39 | const { children } = this.props;
40 | const { hover } = this.state;
41 |
42 | return (
43 |
44 |
this.setState({ hover: true })}
47 | onMouseOut={() => this.setState({ hover: false })}
48 | >
49 | ?
50 |
51 | {hover && (
52 |
53 | {children}
54 |
55 | )}
56 |
57 | );
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/Portal.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import ReactDOM from 'react-dom';
4 | import { canUseDOM } from 'exenv';
5 |
6 | export default class Portal extends React.Component {
7 |
8 | static propTypes = {
9 | onOutClick: PropTypes.func,
10 | };
11 |
12 | constructor(props, context) {
13 | super(props, context);
14 |
15 | if (canUseDOM) {
16 | this.node = document.createElement('div');
17 | this.root = null;
18 | this.handleRootRef = (root) => {
19 | this.root = root;
20 | };
21 |
22 | this.handleOutClick = (e) => {
23 | const { onOutClick } = this.props;
24 | if (typeof onOutClick === 'function') {
25 | if (this.root && !this.root.contains(e.target)) {
26 | onOutClick(e);
27 | }
28 | if (!this.root) {
29 | onOutClick(e);
30 | }
31 | }
32 | };
33 |
34 | document.addEventListener('click', this.handleOutClick, true);
35 | }
36 | }
37 | componentDidMount() {
38 | if (canUseDOM) {
39 | document.body.appendChild(this.node);
40 | }
41 | }
42 |
43 | componentWillUnmount() {
44 | if (canUseDOM) {
45 | document.removeEventListener('click', this.handleOutClick, true);
46 | document.body.removeChild(this.node);
47 | }
48 | }
49 |
50 | render() {
51 | const { onOutClick, ...props } = this.props;
52 |
53 | return ReactDOM.createPortal(
54 | ,
55 | this.node,
56 | );
57 | }
58 |
59 | }
60 |
--------------------------------------------------------------------------------
/demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | react-relative-portal examples
6 |
45 |
46 |
47 |
58 |
59 |
60 |
61 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-relative-portal",
3 | "version": "1.8.0",
4 | "description": "React component for place dropdowns outside overflow: hidden; elements",
5 | "main": "dist/RelativePortal.js",
6 | "files": [
7 | "dist",
8 | "LICENSE",
9 | "README.md"
10 | ],
11 | "keywords": [
12 | "react",
13 | "react-component",
14 | "portal",
15 | "react-portal"
16 | ],
17 | "scripts": {
18 | "build": "yarn clean && babel src/Portal.js > dist/Portal.js && babel src/RelativePortal.js > dist/RelativePortal.js",
19 | "clean": "rm -Rf dist && mkdir dist",
20 | "start-demo": "parcel --out-dir=demo/dist demo/index.html --open",
21 | "build-demo": "parcel build --out-dir=demo/dist --public-url='.' demo/index.html",
22 | "test": "exit 0",
23 | "prepublish": "yarn build"
24 | },
25 | "repository": {
26 | "type": "git",
27 | "url": "git+https://github.com/smartprogress/react-relative-portal.git"
28 | },
29 | "author": "Alex Lunyov ",
30 | "license": "MIT",
31 | "bugs": {
32 | "url": "https://github.com/smartprogress/react-relative-portal/issues"
33 | },
34 | "homepage": "https://github.com/smartprogress/react-relative-portal#readme",
35 | "devDependencies": {
36 | "babel-cli": "^6.10.1",
37 | "babel-core": "^6.26.3",
38 | "babel-eslint": "^6.1.2",
39 | "babel-preset-es2015": "^6.9.0",
40 | "babel-preset-react": "^6.11.1",
41 | "babel-preset-stage-1": "^6.5.0",
42 | "eslint": "^2.13.1",
43 | "eslint-config-airbnb": "^9.0.1",
44 | "eslint-plugin-import": "^1.10.3",
45 | "eslint-plugin-jsx-a11y": "^1.5.5",
46 | "eslint-plugin-react": "^5.2.2",
47 | "parcel-bundler": "^1.11.0",
48 | "prop-types": "^15.6.2",
49 | "react": "^16.7.0",
50 | "react-dom": "^16.7.0"
51 | },
52 | "dependencies": {
53 | "exenv": "^1.2.1",
54 | "lodash.throttle": "^4.1.1",
55 | "prop-types": "^15.6.0"
56 | },
57 | "peerDependencies": {
58 | "react": ">=16.0.0",
59 | "react-dom": ">=16.0.0"
60 | },
61 | "prettier": {
62 | "trailingComma": "es5",
63 | "singleQuote": true
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React relative portal
2 | React component for place dropdown-like components outside overflow: hidden; sections
3 |
4 | ## Installation
5 | `npm install react-relative-portal --save`
6 |
7 | ## Example
8 | ```es6
9 | import React from 'react';
10 | import RelativePortal from 'react-relative-portal';
11 |
12 | export default class DropdownLink extends React.Component {
13 |
14 | constructor(props) {
15 | super(props);
16 |
17 | this.state = {
18 | show: false,
19 | };
20 |
21 | this._setShowAsyncTimer = null;
22 |
23 | this._handleShow = () => {
24 | this._setShowAsync(true);
25 | };
26 |
27 | this._handleHide = () => {
28 | this._setShowAsync(false);
29 | };
30 | }
31 |
32 | componentWillUnmount() {
33 | // Prevent the asynchronous `setState` call after unmount.
34 | clearTimeout(this._setShowAsyncTimer);
35 | }
36 |
37 | /**
38 | * Changes the dropdown show/hide state asynchronously.
39 | *
40 | * Need to change the dropdown state asynchronously,
41 | * otherwise the dropdown gets immediately closed
42 | * during the dropdown toggle's `onClick` which propagates to `onOutClick`.
43 | */
44 | _setShowAsync(show) {
45 | // Prevent multiple asynchronous `setState` calls, jsut the latest has to happen.
46 | clearTimeout(this._setShowAsyncTimer);
47 | this._setShowAsyncTimer = setTimeout(() => {
48 | this.setState({ show: show });
49 | }, 0);
50 | }
51 |
52 | render() {
53 | const { show } = this.state;
54 |
55 | return (
56 |
57 |
60 |
66 | {show &&
67 |
68 | Dropdown content
69 |
70 | }
71 |
72 |
73 | );
74 | }
75 |
76 | }
77 | ```
78 |
79 | ## Props
80 | ```es6
81 | export default class RelativePortal extends React.Component {
82 | static propTypes = {
83 | right: PropTypes.number, // set right offset from current position. If undefined portal positons from left
84 | left: PropTypes.number, // set left offset from current position. If `right` prop is set, `left` ignores
85 | fullWidth: PropTypes.bool, // enables you to set both left and right portal positions
86 | top: PropTypes.number, // set top offset from current position
87 | children: PropTypes.any.isRequired, // portal content
88 | onOutClick: PropTypes.func, // called when user click outside portal element
89 | component: PropTypes.string.isRequired, // dom tagName
90 | };
91 |
92 | static defaultProps = {
93 | left: 0,
94 | top: 0,
95 | component: 'span',
96 | };
97 |
98 | ...
99 |
100 | }
101 | ```
102 |
--------------------------------------------------------------------------------
/src/RelativePortal.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import throttle from 'lodash.throttle';
4 | import { canUseDOM } from 'exenv';
5 | import Portal from './Portal';
6 |
7 | const listeners = {};
8 |
9 | function fireListeners() {
10 | Object.keys(listeners).forEach(key => listeners[key]());
11 | }
12 |
13 | function getPageOffset() {
14 | return {
15 | x: (window.pageXOffset !== undefined)
16 | ? window.pageXOffset
17 | : (document.documentElement || document.body.parentNode || document.body).scrollLeft,
18 | y: (window.pageYOffset !== undefined)
19 | ? window.pageYOffset
20 | : (document.documentElement || document.body.parentNode || document.body).scrollTop,
21 | }
22 | }
23 |
24 | function initDOMListener() {
25 | document.body.addEventListener('wheel', throttle(fireListeners, 100, {
26 | leading: true,
27 | trailing: true,
28 | }));
29 | window.addEventListener('resize', throttle(fireListeners, 50, {
30 | leading: true,
31 | trailing: true,
32 | }));
33 | }
34 |
35 | if (canUseDOM) {
36 | if (document.body) {
37 | initDOMListener();
38 | } else {
39 | document.addEventListener('DOMContentLoaded', initDOMListener);
40 | }
41 | }
42 |
43 | let listenerIdCounter = 0;
44 | function subscribe(fn) {
45 | listenerIdCounter += 1;
46 | const id = listenerIdCounter;
47 | listeners[id] = fn;
48 | return () => delete listeners[id];
49 | }
50 |
51 | export default class RelativePortal extends React.Component {
52 | static propTypes = {
53 | right: PropTypes.number,
54 | left: PropTypes.number,
55 | fullWidth: PropTypes.bool,
56 | top: PropTypes.number,
57 | children: PropTypes.any,
58 | onOutClick: PropTypes.func,
59 | component: PropTypes.string.isRequired,
60 | };
61 |
62 | static defaultProps = {
63 | left: 0,
64 | top: 0,
65 | component: 'span',
66 | };
67 |
68 | state = {
69 | right: 0,
70 | left: 0,
71 | top: 0,
72 | };
73 |
74 | componentDidMount() {
75 | this.handleScroll = () => {
76 | if (this.element) {
77 | const rect = this.element.getBoundingClientRect();
78 | const pageOffset = getPageOffset();
79 | const top = pageOffset.y + rect.top;
80 | const right = document.documentElement.clientWidth - rect.right - pageOffset.x;
81 | const left = pageOffset.x + rect.left;
82 |
83 | if (top !== this.state.top || left !== this.state.left || right !== this.state.right) {
84 | this.setState({ left, top, right });
85 | }
86 | }
87 | };
88 | this.unsubscribe = subscribe(this.handleScroll);
89 | this.handleScroll();
90 | }
91 |
92 | componentDidUpdate() {
93 | this.handleScroll();
94 | }
95 |
96 | componentWillUnmount() {
97 | this.unsubscribe();
98 | }
99 |
100 | render() {
101 | const { component: Comp, top, left, right, fullWidth, ...props } = this.props;
102 |
103 | const fromLeftOrRight = right !== undefined ?
104 | { right: this.state.right + right } :
105 | { left: this.state.left + left };
106 |
107 | const horizontalPosition = fullWidth ?
108 | { right: this.state.right + right, left: this.state.left + left } : fromLeftOrRight;
109 |
110 | return (
111 | {
113 | this.element = element;
114 | }}
115 | >
116 |
117 |
124 | {this.props.children}
125 |
126 |
127 |
128 | );
129 | }
130 | }
131 |
--------------------------------------------------------------------------------
/demo/Demo.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import Hint from './Hint';
4 | import BadHint from './BadHint';
5 | import Dropdown from './Dropdown';
6 | import BadDropdown from './BadDropdown';
7 |
8 | const styles = {
9 | section: {
10 | margin: '0 0 40px',
11 | },
12 | oh: {
13 | position: 'relative',
14 | overflow: 'hidden',
15 | width: 100,
16 | height: 50,
17 | backgroundColor: '#F7F7F7',
18 | },
19 | };
20 |
21 | export default class Demo extends React.Component {
22 | constructor(props) {
23 | super(props);
24 | this.state = {
25 | toggleContent: false,
26 | };
27 | }
28 | render() {
29 | return (
30 |
31 |
32 |
33 |
34 |
35 |
36 | 💩
37 | Without react-relative-portal
38 |
39 | |
40 |
41 |
42 | 🎉
43 | With react-relative-portal
44 |
45 | |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | Hint content
54 |
55 |
56 |
57 |
58 |
59 |
60 |
69 | {this.state.toggleContent ? 'Dropdown content' : ''}
70 |
71 |
72 |
73 |
74 |
75 |
76 | Dropdown content
77 |
78 |
79 | |
80 |
81 |
82 |
83 | Hint content
84 |
85 |
86 |
87 |
88 |
89 |
90 |
100 | {this.state.toggleContent ? 'Dropdown content' : ''}
101 |
102 |
103 |
104 |
105 |
106 |
107 | Dropdown content
108 |
109 |
110 | |
111 |
112 |
113 |
114 |
115 | );
116 | }
117 | }
118 |
--------------------------------------------------------------------------------