├── .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 |
46 |
{children}
47 |
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 |
48 |
49 |

50 | react-relative-portal 53 | examples 54 |

55 |
56 |
57 |
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 | 40 | 46 | 47 | 48 | 49 | 50 | 80 | 111 | 112 | 113 |
35 |
36 | 💩 37 |

Without react-relative-portal

38 |
39 |
41 |
42 | 🎉 43 |

With react-relative-portal

44 |
45 |
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 |
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 |
114 |
115 | ); 116 | } 117 | } 118 | --------------------------------------------------------------------------------