├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierrc.js ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── demo └── src │ └── index.js ├── nwb.config.js ├── package-lock.json ├── package.json ├── src ├── Divider.js ├── Panel.js ├── PanelGroup.js └── index.js └── tests ├── .eslintrc └── index-test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | 10 | # Change these settings to your own preference 11 | indent_style = space 12 | indent_size = 2 13 | 14 | # We recommend you to keep these unchanged 15 | end_of_line = lf 16 | charset = utf-8 17 | trim_trailing_whitespace = true 18 | insert_final_newline = true 19 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | es 2 | lib 3 | demo 4 | node_modules 5 | umd 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: 'babel-eslint', 3 | extends: 'airbnb', 4 | env: { 5 | browser: true 6 | }, 7 | rules: { 8 | 'comma-dangle': 0, 9 | 'no-param-reassign': 0, // comeback to this, 10 | 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }], 11 | 'react/sort-comp': 0, 12 | 'react/jsx-filename-extension': 0, 13 | 'react/no-string-refs': 0, 14 | 'react/forbid-prop-types': 0, 15 | 'jsx-a11y/no-static-element-interactions': 0, 16 | 'no-mixed-operators': 0, 17 | 'function-paren-newline': 0 18 | }, 19 | overrides: [ 20 | { 21 | files: ['tests/**/*test.js'], 22 | env: { 23 | mocha: true 24 | }, 25 | rules: { 26 | 'import/extensions': 0, 27 | 'import/no-unresolved': 0, 28 | 'import/no-extraneous-dependencies': 0 29 | } 30 | } 31 | ] 32 | }; 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /demo/dist 3 | /es 4 | /lib 5 | /node_modules 6 | /umd 7 | npm-debug.log* 8 | .babelrc 9 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | printWidth: 100, 4 | arrowParens: 'always' 5 | }; 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: node_js 4 | node_js: 5 | - 6 6 | - 8 7 | 8 | cache: 9 | directories: 10 | - node_modules 11 | 12 | before_install: 13 | - npm install codecov.io coveralls 14 | 15 | after_success: 16 | - cat ./coverage/lcov.info | ./node_modules/codecov.io/bin/codecov.io.js 17 | - cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js 18 | 19 | branches: 20 | only: 21 | - master 22 | - release 23 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Prerequisites 2 | 3 | [Node.js](http://nodejs.org/) >= v4 must be installed. 4 | 5 | ## Installation 6 | 7 | - Running `npm install` in the components's root directory will install everything you need for development. 8 | 9 | ## Demo Development Server 10 | 11 | - `npm start` will run a development server with the component's demo app at [http://localhost:3000](http://localhost:3000) with hot module reloading. 12 | 13 | ## Running Tests 14 | 15 | - `npm test` will run the tests once. 16 | 17 | - `npm run test:coverage` will run the tests and produce a coverage report in `coverage/`. 18 | 19 | - `npm run test:watch` will run the tests on every change. 20 | 21 | ## Building 22 | 23 | - `npm run build` will build the component for publishing to npm and also bundle the demo app. 24 | 25 | - `npm run clean` will delete built resources. 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Dan Fessler 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## [React-PanelGroup](https://danfessler.github.io/react-panelgroup/) [![Travis][build-badge]][build] [![PRs Welcome][pr-badge]][prwelcome] 2 | 3 | A React component for resizable panel group layouts
4 | 5 | Demo: [https://danfessler.github.io/react-panelgroup/](https://danfessler.github.io/react-panelgroup/) 6 | 7 | [build-badge]: https://img.shields.io/travis/DanFessler/react-panelgroup/master.svg?style=flat 8 | [build]: https://travis-ci.org/DanFessler/react-panelgroup 9 | [pr-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg 10 | [prwelcome]: CONTRIBUTING.md 11 | 12 | ## Features 13 | 14 | * **Absolute & Relative Sizing** 15 | Choose between absolute pixel sizing and relative weights to describe your layout. Even mix the two per panel for more complex layouts. Supports fixed-size, dynamic (absolute pixel), and stretchy (relative weights) resizing 16 | * **Neighbor-Aware Resizing** 17 | When a panel is resized beyond it's extents, it will begin to push or pull at it's neighbors recursively. 18 | * **Column & Row Orientations** 19 | Supports vertical and horizontal orientations. Nest them together to produce grid-like layouts 20 | * **Snap points** 21 | If supplied, panels can snap to pre-defined sizes 22 | 23 | ## Installation 24 | 25 | ```sh 26 | $ npm install --save react-panelgroup 27 | ``` 28 | 29 | ## Examples 30 | 31 | ### Defaults 32 | 33 | When not specifying any props, the panel group defaults to a horizontal orientation with panels of equal (stretchy) widths. PanelGroup will always try to entirely fill it's container. 34 | 35 | ```jsx 36 | 37 |
panel 1
38 |
panel 2
39 |
panel 3
40 |
41 | ``` 42 | 43 | ### Column layout 44 | 45 | Setting the direction prop to "column" will result in a vertical layout 46 | 47 | ```jsx 48 | 49 |
panel 1
50 |
panel 2
51 |
panel 3
52 |
53 | ``` 54 | 55 | ### Nested layout 56 | 57 | Nest multiple panelGroups for more complex layouts 58 | 59 | ```jsx 60 | 61 | 62 |
panel 1
63 |
panel 2
64 |
panel 3
65 |
66 |
panel 4
67 | 68 |
panel 5
69 |
panel 6
70 |
71 |
72 | ``` 73 | 74 | ### Defined panel sizes 75 | 76 | Providing panelWidths with an array of objects defining each panel's size parameters will set the initial sizing for each panel. If any property is missing, it will resort to the default for that property. 77 | 78 | ```jsx 79 | 86 |
panel 1
87 |
panel 2
88 |
panel 3
89 |
90 | ``` 91 | 92 | ## Component Props 93 | 94 | * `spacing: number`
95 | sets the width of the border between each panel

96 | * `borderColor: Valid CSS color string`
97 | Optionally defines a border color for panel dividers. Defaults to "transparent"

98 | * `panelColor: Valid CSS color string`
99 | Optionally defines a background color for the panels. Defaults to "transparent"

100 | * `direction: [ "row" | "column" ]`
101 | Sets the orientation of the panel group

102 | * `panelWidths: [panelWidth, ...]`
103 | An array of panelWidth objects to initialize each panel with. If a property is missing, or an index is null, it will resort to default values

104 | * `panelWidth.size: number`
105 | Initial panel size. If panelWidth.resize is "fixed" or "dynamic" the size will be pixel units. If panelWidth.resize is "stretch" then it is treated as a relative weight: Defaults to 256

106 | * `panelWidth.minSize: number`
107 | minimum size of panel in pixels. Defaults to 48

108 | * `panelWidth.maxSize: number`
109 | maximum size of panel in pixels. Defaults to 0 (No Max Width)

110 | * `panelWidth.resize: [ "fixed" | "dynamic" | "stretch" ]`
111 | Sets the resize behavior of the panel. Fixed cannot be resized. Defaults to "stretch"

112 | * `panelWidth.snap: [snapPoint, ...]`
113 | An array of positions to snap to per panel

114 | * `onUpdate: function()`
115 | Callback to receive state updates from PanelGroup to allow controlling state externally. Returns an array of panelWidths

116 | * `onResizeStart: function(panels)`
117 | Callback fired when resizing started, receives state of panels

118 | * `onResizeEnd: function(panels)`
119 | Callback fired when resizing ends, receives state

120 | 121 | ## Contribute 122 | 123 | ### Prerequisites 124 | 125 | [Node.js](http://nodejs.org/) >= v4 must be installed. 126 | 127 | ### Installation 128 | 129 | * Running `npm install` in the components's root directory will install everything you need for development. 130 | 131 | **NOTE** yarn does not work! It will yield phantomjs errors. 132 | 133 | ### Demo Development Server 134 | 135 | * `npm start` will run a development server with the component's demo app at [http://localhost:3000](http://localhost:3000) with hot module reloading. 136 | 137 | ### Running Tests 138 | 139 | * `npm test` will run the tests once. 140 | * `npm run test:coverage` will run the tests and produce a coverage report in `coverage/`. 141 | * `npm run test:watch` will run the tests on every change. 142 | 143 | ### Building 144 | 145 | * `npm run build` will build the component for publishing to npm and also bundle the demo app. 146 | * `npm run clean` will delete built resources. 147 | -------------------------------------------------------------------------------- /demo/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "react-dom"; 3 | 4 | import PanelGroup from "../../src/PanelGroup"; 5 | 6 | var code1 = ` 7 | panel 1 8 | panel 2 9 | panel 3 10 | `; 11 | 12 | var code2 = ` 13 | panel 1 14 | panel 2 15 | panel 3 16 | `; 17 | 18 | var code3 = ` 19 | 20 | panel 1 21 | panel 2 22 | panel 3 23 | 24 | panel 4 25 | 26 | panel 5 27 | panel 6 28 | 29 | `; 30 | 31 | var code4 = ` 36 | panel 1 37 | panel 2 38 | panel 3 39 | `; 40 | 41 | class Demo extends React.Component { 42 | render() { 43 | var containerStyle = { 44 | width: 640, 45 | height: 320, 46 | flexGrow: 1, 47 | flexShrink: 1 48 | }; 49 | var rowStyle = { 50 | display: "flex", 51 | marginBottom: 32, 52 | border: "1px solid grey", 53 | borderRadius: 8, 54 | overflow: "hidden" 55 | }; 56 | var codeStyle = { 57 | flexGrow: 0, 58 | flexShrink: 0, 59 | width: 420, 60 | margin: 0, 61 | padding: 16, 62 | backgroundColor: "#DDD", 63 | overflowY: "auto", 64 | borderRight: "1px solid grey" 65 | }; 66 | return ( 67 |
68 |

React-PanelGroup Demo

69 | 70 |

Default Values

71 |
72 |
{code1}
73 |
74 | 75 |
76 |
77 | 78 |

Column layout

79 |
80 |
{code2}
81 |
82 | 83 |
84 |
85 | 86 |

Nested layout

87 |
88 |
{code3}
89 |
90 | 91 |
92 |
93 | 94 |

Defined panel sizes

95 |
96 |
{code4}
97 |
98 | 99 |
100 |
101 |
102 | ); 103 | } 104 | } 105 | 106 | let DefaultLayout = function(props) { 107 | return ( 108 | 109 | panel 1 110 | panel 2 111 | panel 3 112 | 113 | ); 114 | }; 115 | 116 | let ColumnLayout = function(props) { 117 | return ( 118 | 119 | panel 1 120 | panel 2 121 | panel 3 122 | 123 | ); 124 | }; 125 | 126 | let NestedLayout = function(props) { 127 | var containerStyle = { 128 | width: "100%", 129 | height: "100%", 130 | flexGrow: 1, 131 | flexShrink: 1 132 | }; 133 | return ( 134 | 135 | 136 | panel 1 137 | panel 2 138 | panel 3 139 | 140 | panel 4 141 | 142 | panel 5 143 | panel 6 144 | 145 | 146 | ); 147 | }; 148 | 149 | let DefinedLayout = function(props) { 150 | return ( 151 | 160 | panel 1 161 | panel 2 162 | panel 3 163 | 164 | ); 165 | }; 166 | 167 | let Content = function(props) { 168 | return
{props.children}
; 169 | }; 170 | 171 | render(, document.querySelector("#demo")); 172 | -------------------------------------------------------------------------------- /nwb.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | type: 'react-component', 3 | npm: { 4 | esModules: true, 5 | umd: { 6 | global: 'ReactPanelGroup', 7 | externals: { 8 | react: 'React' 9 | } 10 | } 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-panelgroup", 3 | "version": "1.0.12", 4 | "description": "react-panelgroup React component", 5 | "main": "lib/index.js", 6 | "jsnext:main": "es/index.js", 7 | "module": "es/index.js", 8 | "files": [ 9 | "css", 10 | "es", 11 | "lib", 12 | "umd" 13 | ], 14 | "scripts": { 15 | "build": "nwb build-react-component", 16 | "clean": "nwb clean-module && npm clean-demo", 17 | "start": "nwb serve-react-demo", 18 | "test": "npm run lint && nwb test", 19 | "test:coverage": "nwb test --coverage", 20 | "test:watch": "nwb test --server", 21 | "deploy": "gh-pages -d demo/dist", 22 | "lint": "eslint --quiet ./**/*.js" 23 | }, 24 | "dependencies": { 25 | "prop-types": "^15.6.1" 26 | }, 27 | "peerDependencies": { 28 | "react": "16.x || 15.x" 29 | }, 30 | "devDependencies": { 31 | "babel-eslint": "^8.2.3", 32 | "eslint": "^4.19.1", 33 | "eslint-config-airbnb": "^16.1.0", 34 | "eslint-plugin-import": "^2.11.0", 35 | "eslint-plugin-jsx-a11y": "^6.0.3", 36 | "eslint-plugin-react": "^7.7.0", 37 | "gh-pages": "^0.11.0", 38 | "nwb": "0.22.x", 39 | "react": "^16.0.0", 40 | "react-dom": "^16.0.0" 41 | }, 42 | "author": "Dan Fessler", 43 | "homepage": "http://www.DanFessler.com", 44 | "license": "MIT", 45 | "repository": "https://github.com/DanFessler/react-panelgroup", 46 | "keywords": [ 47 | "react-component" 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /src/Divider.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export default class Divider extends React.Component { 5 | static propTypes = { 6 | dividerWidth: PropTypes.number, 7 | handleBleed: PropTypes.number, 8 | direction: PropTypes.string, 9 | panelID: PropTypes.number.isRequired, 10 | handleResize: PropTypes.func.isRequired, 11 | showHandles: PropTypes.bool, 12 | borderColor: PropTypes.string, 13 | onResizeStart: PropTypes.func, 14 | onResizeEnd: PropTypes.func 15 | }; 16 | 17 | static defaultProps = { 18 | dividerWidth: 1, 19 | handleBleed: 4, 20 | direction: undefined, 21 | showHandles: false, 22 | borderColor: undefined, 23 | onResizeStart: undefined, 24 | onResizeEnd: undefined 25 | }; 26 | 27 | constructor(...args) { 28 | super(...args); 29 | 30 | this.state = { 31 | dragging: false, 32 | initPos: { x: null, y: null } 33 | }; 34 | } 35 | 36 | // Add/remove event listeners based on drag state 37 | componentDidUpdate(props, state) { 38 | if (this.state.dragging && !state.dragging) { 39 | document.addEventListener('mousemove', this.onMouseMove); 40 | document.addEventListener('touchmove', this.onTouchMove, { 41 | passive: false 42 | }); 43 | document.addEventListener('mouseup', this.handleDragEnd); 44 | document.addEventListener('touchend', this.handleDragEnd, { 45 | passive: false 46 | }); 47 | // maybe move it to setState callback ? 48 | this.props.onResizeStart(); 49 | } else if (!this.state.dragging && state.dragging) { 50 | document.removeEventListener('mousemove', this.onMouseMove); 51 | document.removeEventListener('touchmove', this.onTouchMove, { 52 | passive: false 53 | }); 54 | document.removeEventListener('mouseup', this.handleDragEnd); 55 | document.removeEventListener('touchend', this.handleDragEnd, { 56 | passive: false 57 | }); 58 | this.props.onResizeEnd(); 59 | } 60 | } 61 | 62 | // Start drag state and set initial position 63 | handleDragStart = (e, x, y) => { 64 | this.setState({ 65 | dragging: true, 66 | initPos: { 67 | x, 68 | y 69 | } 70 | }); 71 | 72 | e.stopPropagation(); 73 | e.preventDefault(); 74 | }; 75 | 76 | // End drag state 77 | handleDragEnd = (e) => { 78 | this.setState({ dragging: false }); 79 | e.stopPropagation(); 80 | e.preventDefault(); 81 | }; 82 | 83 | // Call resize handler if we're dragging 84 | handleDragMove = (e, x, y) => { 85 | if (!this.state.dragging) return; 86 | 87 | const initDelta = { 88 | x: x - this.state.initPos.x, 89 | y: y - this.state.initPos.y 90 | }; 91 | 92 | const flowMask = { 93 | x: this.props.direction === 'row' ? 1 : 0, 94 | y: this.props.direction === 'column' ? 1 : 0 95 | }; 96 | 97 | const flowDelta = initDelta.x * flowMask.x + initDelta.y * flowMask.y; 98 | 99 | // Resize the panels 100 | const resultDelta = this.handleResize(this.props.panelID, initDelta); 101 | 102 | // if the divider moved, reset the initPos 103 | if (resultDelta + flowDelta !== 0) { 104 | // Did we move the expected amount? (snapping will result in a larger delta) 105 | const expectedDelta = resultDelta === flowDelta; 106 | 107 | this.setState({ 108 | initPos: { 109 | // if we moved more than expected, add the difference to the Position 110 | x: x + (expectedDelta ? 0 : resultDelta * flowMask.x), 111 | y: y + (expectedDelta ? 0 : resultDelta * flowMask.y) 112 | } 113 | }); 114 | } 115 | 116 | e.stopPropagation(); 117 | e.preventDefault(); 118 | }; 119 | 120 | // Call resize on mouse events 121 | // Event onMosueDown 122 | onMouseDown = (e) => { 123 | // only left mouse button 124 | if (e.button !== 0) return; 125 | this.handleDragStart(e, e.pageX, e.pageY); 126 | }; 127 | // Event onMouseMove 128 | onMouseMove = (e) => { 129 | this.handleDragMove(e, e.pageX, e.pageY); 130 | }; 131 | 132 | // Call resize on Touch events (mobile) 133 | // Event ontouchstart 134 | onTouchStart = (e) => { 135 | this.handleDragStart(e, e.touches[0].clientX, e.touches[0].clientY); 136 | }; 137 | 138 | // Event ontouchmove 139 | onTouchMove = (e) => { 140 | this.handleDragMove(e, e.touches[0].clientX, e.touches[0].clientY); 141 | }; 142 | 143 | // Handle resizing 144 | handleResize = (i, delta) => this.props.handleResize(i, delta); 145 | 146 | // Utility functions for handle size provided how much bleed 147 | // we want outside of the actual divider div 148 | getHandleWidth = () => this.props.dividerWidth + this.props.handleBleed * 2; 149 | getHandleOffset = () => this.props.dividerWidth / 2 - this.getHandleWidth() / 2; 150 | 151 | // Render component 152 | render() { 153 | const style = { 154 | divider: { 155 | width: this.props.direction === 'row' ? this.props.dividerWidth : 'auto', 156 | minWidth: this.props.direction === 'row' ? this.props.dividerWidth : 'auto', 157 | maxWidth: this.props.direction === 'row' ? this.props.dividerWidth : 'auto', 158 | height: this.props.direction === 'column' ? this.props.dividerWidth : 'auto', 159 | minHeight: this.props.direction === 'column' ? this.props.dividerWidth : 'auto', 160 | maxHeight: this.props.direction === 'column' ? this.props.dividerWidth : 'auto', 161 | flexGrow: 0, 162 | position: 'relative' 163 | }, 164 | handle: { 165 | position: 'absolute', 166 | width: this.props.direction === 'row' ? this.getHandleWidth() : '100%', 167 | height: this.props.direction === 'column' ? this.getHandleWidth() : '100%', 168 | left: this.props.direction === 'row' ? this.getHandleOffset() : 0, 169 | top: this.props.direction === 'column' ? this.getHandleOffset() : 0, 170 | backgroundColor: this.props.showHandles ? 'rgba(0,128,255,0.25)' : 'auto', 171 | cursor: this.props.direction === 'row' ? 'col-resize' : 'row-resize', 172 | zIndex: 100 173 | } 174 | }; 175 | Object.assign(style.divider, { backgroundColor: this.props.borderColor }); 176 | 177 | // Add custom class if dragging 178 | let className = 'divider'; 179 | if (this.state.dragging) { 180 | className += ' dragging'; 181 | } 182 | 183 | return ( 184 |
190 |
191 |
192 | ); 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/Panel.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export default class Panel extends React.Component { 5 | static propTypes = { 6 | resize: PropTypes.string, 7 | onWindowResize: PropTypes.func, 8 | panelID: PropTypes.number.isRequired, 9 | style: PropTypes.object.isRequired, 10 | children: PropTypes.oneOfType([PropTypes.array, PropTypes.object]).isRequired 11 | }; 12 | 13 | static defaultProps = { 14 | resize: undefined, 15 | onWindowResize: undefined 16 | }; 17 | // Find the resizeObject if it has one 18 | componentDidMount() { 19 | if (this.props.resize === 'stretch') { 20 | this.refs.resizeObject.addEventListener('load', () => this.onResizeObjectLoad()); 21 | this.refs.resizeObject.data = 'about:blank'; 22 | this.calculateStretchWidth(); // this.onNextFrame(this.calculateStretchWidth); 23 | } 24 | } 25 | 26 | // Attach resize event listener to resizeObject 27 | onResizeObjectLoad = () => { 28 | this.refs.resizeObject.contentDocument.defaultView.addEventListener('resize', () => 29 | this.calculateStretchWidth() 30 | ); 31 | }; 32 | 33 | // Utility function to wait for next render before executing a function 34 | onNextFrame = (callback) => { 35 | setTimeout(() => { 36 | window.requestAnimationFrame(callback); 37 | }, 0); 38 | }; 39 | 40 | // Recalculate the stretchy panel if it's container has been resized 41 | calculateStretchWidth = () => { 42 | if (this.props.onWindowResize !== null) { 43 | const rect = this.node.getBoundingClientRect(); 44 | 45 | this.props.onWindowResize( 46 | this.props.panelID, 47 | { x: rect.width, y: rect.height }, 48 | undefined, 49 | this.node.parentElement 50 | // recalcalculate again if the width is below minimum 51 | // Kinda hacky, but for large resizes like fullscreen/Restore 52 | // it can't solve it in one pass. 53 | // function() {this.onNextFrame(this.calculateStretchWidth)}.bind(this) 54 | ); 55 | } 56 | }; 57 | 58 | createResizeObject() { 59 | const style = { 60 | resizeObject: { 61 | position: 'absolute', 62 | top: 0, 63 | left: 0, 64 | width: '100%', 65 | height: '100%', 66 | zIndex: -1, 67 | opacity: 0 68 | } 69 | }; 70 | 71 | // only attach resize object if panel is stretchy. Others dont need it 72 | return this.props.resize === 'stretch' ? ( 73 | 74 | ) : null; 75 | } 76 | 77 | // Render component 78 | render() { 79 | const resizeObject = this.createResizeObject(); 80 | 81 | return ( 82 |
{ 84 | this.node = node; 85 | }} 86 | className="panelWrapper" 87 | style={this.props.style} 88 | > 89 | {resizeObject} 90 | {this.props.children} 91 |
92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/PanelGroup.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import Panel from './Panel'; 5 | import Divider from './Divider'; 6 | 7 | export { Divider, Panel }; 8 | 9 | export default class PanelGroup extends React.Component { 10 | static defaultProps = { 11 | spacing: 1, 12 | direction: 'row', 13 | panelWidths: [], 14 | onUpdate: undefined, 15 | onResizeStart: undefined, 16 | onResizeEnd: undefined, 17 | panelColor: undefined, 18 | borderColor: undefined, 19 | showHandles: false 20 | }; 21 | 22 | static propTypes = { 23 | spacing: PropTypes.number, 24 | direction: PropTypes.string, 25 | panelWidths: PropTypes.array, 26 | children: PropTypes.oneOfType([PropTypes.array, PropTypes.object]).isRequired, 27 | onUpdate: PropTypes.func, 28 | onResizeStart: PropTypes.func, 29 | onResizeEnd: PropTypes.func, 30 | panelColor: PropTypes.string, 31 | borderColor: PropTypes.string, 32 | showHandles: PropTypes.bool 33 | }; 34 | // Load initial panel configuration from props 35 | constructor(...args) { 36 | super(...args); 37 | 38 | this.state = this.loadPanels(this.props); 39 | } 40 | 41 | // reload panel configuration if props update 42 | componentWillReceiveProps(nextProps) { 43 | const nextPanels = nextProps.panelWidths; 44 | 45 | // Only update from props if we're supplying the props in the first place 46 | if (nextPanels.length) { 47 | // if the panel array is a different size we know to update 48 | if (this.state.panels.length !== nextPanels.length) { 49 | this.setState(this.loadPanels(nextProps)); 50 | } else { 51 | // otherwise we need to iterate to spot any difference 52 | for (let i = 0; i < nextPanels.length; i++) { 53 | if ( 54 | this.state.panels[i].size !== nextPanels[i].size || 55 | this.state.panels[i].minSize !== nextPanels[i].minSize || 56 | this.state.panels[i].maxSize !== nextPanels[i].maxSize || 57 | this.state.panels[i].resize !== nextPanels[i].resize 58 | ) { 59 | this.setState(this.loadPanels(nextProps)); 60 | break; 61 | } 62 | } 63 | } 64 | } 65 | } 66 | defaultResize = (props, index, defaultResize) => { 67 | let resize = defaultResize; 68 | if (props.panelWidths[index].resize) { 69 | resize = props.panelWidths[index].resize; // eslint-disable-line 70 | } else { 71 | resize = props.panelWidths[index].size ? 'dynamic' : resize; 72 | } 73 | return resize; 74 | }; 75 | // load provided props into state 76 | loadPanels = (props) => { 77 | const panels = []; 78 | 79 | if (props.children) { 80 | // Default values if none were provided 81 | const defaultSize = 256; 82 | const defaultMinSize = 48; 83 | const defaultMaxSize = 0; 84 | const defaultResize = 'stretch'; 85 | 86 | let stretchIncluded = false; 87 | const children = React.Children.toArray(props.children); 88 | 89 | for (let i = 0; i < children.length; i++) { 90 | if (i < props.panelWidths.length && props.panelWidths[i]) { 91 | const widthObj = { 92 | size: props.panelWidths[i].size !== undefined ? props.panelWidths[i].size : defaultSize, 93 | minSize: 94 | props.panelWidths[i].minSize !== undefined 95 | ? props.panelWidths[i].minSize 96 | : defaultMinSize, 97 | maxSize: 98 | props.panelWidths[i].maxSize !== undefined 99 | ? props.panelWidths[i].maxSize 100 | : defaultMaxSize, 101 | resize: this.defaultResize(props, i, defaultResize), 102 | snap: props.panelWidths[i].snap !== undefined ? props.panelWidths[i].snap : [], 103 | style: { 104 | // making the ability to not have to be so terse for style settings on panel 105 | ...this.getPanelClass().defaultProps.style, 106 | ...(props.panelWidths[i].style || {}) 107 | } 108 | }; 109 | panels.push(widthObj); 110 | } else { 111 | // default values if no props are given 112 | panels.push({ 113 | size: defaultSize, 114 | resize: defaultResize, 115 | minSize: defaultMinSize, 116 | maxSize: defaultMaxSize, 117 | snap: [], 118 | style: {} 119 | }); 120 | } 121 | 122 | // if none of the panels included was stretchy, make the last one stretchy 123 | if (panels[i].resize === 'stretch') stretchIncluded = true; 124 | if (!stretchIncluded && i === children.length - 1) panels[i].resize = 'stretch'; 125 | } 126 | } 127 | 128 | return { 129 | panels 130 | }; 131 | }; 132 | 133 | // Pass internal state out if there's a callback for it 134 | // Useful for saving panel configuration 135 | onUpdate = (panels) => { 136 | if (this.props.onUpdate) { 137 | this.props.onUpdate(panels.slice()); 138 | } 139 | }; 140 | 141 | onResizeStart = () => { 142 | if (this.props.onResizeStart) { 143 | // actually this slice clones only array, underlying objects stays the same 144 | this.props.onResizeStart(this.state.panels.slice()); 145 | } 146 | }; 147 | 148 | onResizeEnd = () => { 149 | if (this.props.onResizeEnd) { 150 | this.props.onResizeEnd(this.state.panels.slice()); 151 | } 152 | }; 153 | 154 | // For styling, track which direction to apply sizing to 155 | getSizeDirection = (caps) => { 156 | if (caps) { 157 | return this.props.direction === 'column' ? 'Height' : 'Width'; 158 | } 159 | return this.props.direction === 'column' ? 'height' : 'width'; 160 | }; 161 | 162 | getStyle() { 163 | const container = { 164 | width: '100%', 165 | height: '100%', 166 | [`min${this.getSizeDirection(true)}`]: this.getPanelGroupMinSize(this.props.spacing), 167 | display: 'flex', 168 | flexDirection: this.props.direction, 169 | flexGrow: 1 170 | }; 171 | 172 | return { 173 | container, 174 | panel: { 175 | flexGrow: 0, 176 | display: 'flex' 177 | } 178 | }; 179 | } 180 | 181 | getPanelStyle(index) { 182 | const { direction, panelColor } = this.props; 183 | 184 | const panel = this.state.panels[index]; 185 | const { style } = panel; 186 | 187 | // setting up the style for this panel. Should probably be handled 188 | // in the child component, but this was easier for now 189 | let newPanelStyle = { 190 | [this.getSizeDirection()]: panel.size, 191 | [direction === 'row' ? 'height' : 'width']: '100%', 192 | [`min${this.getSizeDirection(true)}`]: panel.resize === 'stretch' ? 0 : panel.size, 193 | 194 | flexGrow: panel.resize === 'stretch' ? 1 : 0, 195 | flexShrink: panel.resize === 'stretch' ? 1 : 0, 196 | display: 'flex', 197 | overflow: 'hidden', 198 | position: 'relative', 199 | ...style 200 | }; 201 | if (panelColor !== null) { 202 | // patch in the background color if it was supplied as a prop 203 | newPanelStyle = { 204 | ...newPanelStyle, 205 | backgroundColor: panelColor 206 | }; 207 | } 208 | 209 | return newPanelStyle; 210 | } 211 | 212 | createPanelProps({ panelStyle, index, initialChildren }) { 213 | const panelState = this.state.panels[index]; 214 | let stretchIncluded = false; 215 | // give position info to children 216 | const metadata = { 217 | isFirst: index === 0, 218 | isLast: index === initialChildren.length - 1, 219 | resize: panelState.resize, 220 | 221 | // window resize handler if this panel is stretchy 222 | onWindowResize: panelState.resize === 'stretch' ? this.setPanelSize : null 223 | }; 224 | 225 | // if none of the panels included was stretchy, make the last one stretchy 226 | if (panelState.resize === 'stretch') stretchIncluded = true; 227 | if (!stretchIncluded && metadata.isLast) metadata.resize = 'stretch'; 228 | 229 | return { 230 | style: panelStyle, 231 | key: index, 232 | panelID: index, 233 | ...metadata 234 | }; 235 | } 236 | 237 | createPanel({ panelStyle, index, initialChildren }) { 238 | const Klass = this.getPanelClass(); 239 | return ( 240 | 241 | {initialChildren[index]} 242 | 243 | ); 244 | } 245 | // eslint-disable-next-line class-methods-use-this 246 | getPanelClass() { 247 | // mainly for accessing default props of panels 248 | return Panel; 249 | } 250 | 251 | maybeDivide({ initialChildren, newChildren, index }) { 252 | // add a handle between panels 253 | if (index < initialChildren.length - 1) { 254 | newChildren.push( 255 | 266 | ); 267 | } 268 | } 269 | 270 | // Entry point for resizing panels. 271 | // We clone the panel array and perform operations on it so we can 272 | // setState after the recursive operations are finished 273 | handleResize = (i, delta) => { 274 | const tempPanels = this.state.panels.slice(); 275 | const returnDelta = this.resizePanel( 276 | i, 277 | this.props.direction === 'row' ? delta.x : delta.y, 278 | tempPanels 279 | ); 280 | this.setState({ panels: tempPanels }); 281 | this.onUpdate(tempPanels); 282 | return returnDelta; 283 | }; 284 | 285 | // Recursive panel resizing so we can push other panels out of the way 286 | // if we've exceeded the target panel's extents 287 | resizePanel = (panelIndex, delta, panels) => { 288 | // 1) first let's calculate and make sure all the sizes add up to be correct. 289 | let masterSize = 0; 290 | for (let iti = 0; iti < panels.length; iti += 1) { 291 | masterSize += panels[iti].size; 292 | } 293 | const boundingRect = this.node.getBoundingClientRect(); 294 | const boundingSize = 295 | (this.props.direction === 'column' ? boundingRect.height : boundingRect.width) - 296 | this.props.spacing * (this.props.children.length - 1); 297 | if (Math.abs(boundingSize - masterSize) <= 0.01) { 298 | // Debug log 299 | // console.log({ panels }, `ERROR! SIZES DON'T MATCH!: ${masterSize}, ${boundingSize}`) 300 | 301 | // 2) Rectify the situation by adding all the unacounted for space to the first panel 302 | panels[panelIndex].size += boundingSize - masterSize; 303 | } 304 | 305 | let minsize; 306 | let maxsize; 307 | 308 | // track the progressive delta so we can report back how much this panel 309 | // actually moved after all the adjustments have been made 310 | let resultDelta = delta; 311 | 312 | // make the changes and deal with the consequences later 313 | panels[panelIndex].size += delta; 314 | panels[panelIndex + 1].size -= delta; 315 | 316 | // Min and max for LEFT panel 317 | minsize = this.getPanelMinSize(panelIndex, panels); 318 | maxsize = this.getPanelMaxSize(panelIndex, panels); 319 | 320 | // if we made the left panel too small 321 | if (panels[panelIndex].size < minsize) { 322 | delta = minsize - panels[panelIndex].size; 323 | 324 | if (panelIndex === 0) { 325 | resultDelta = this.resizePanel(panelIndex, delta, panels); 326 | } else { 327 | resultDelta = this.resizePanel(panelIndex - 1, -delta, panels); 328 | } 329 | } 330 | 331 | // if we made the left panel too big 332 | if (maxsize !== 0 && panels[panelIndex].size > maxsize) { 333 | delta = panels[panelIndex].size - maxsize; 334 | 335 | if (panelIndex === 0) { 336 | resultDelta = this.resizePanel(panelIndex, -delta, panels); 337 | } else { 338 | resultDelta = this.resizePanel(panelIndex - 1, delta, panels); 339 | } 340 | } 341 | 342 | // Min and max for RIGHT panel 343 | minsize = this.getPanelMinSize(panelIndex + 1, panels); 344 | maxsize = this.getPanelMaxSize(panelIndex + 1, panels); 345 | 346 | // if we made the right panel too small 347 | if (panels[panelIndex + 1].size < minsize) { 348 | delta = minsize - panels[panelIndex + 1].size; 349 | 350 | if (panelIndex + 1 === panels.length - 1) { 351 | resultDelta = this.resizePanel(panelIndex, -delta, panels); 352 | } else { 353 | resultDelta = this.resizePanel(panelIndex + 1, delta, panels); 354 | } 355 | } 356 | 357 | // if we made the right panel too big 358 | if (maxsize !== 0 && panels[panelIndex + 1].size > maxsize) { 359 | delta = panels[panelIndex + 1].size - maxsize; 360 | 361 | if (panelIndex + 1 === panels.length - 1) { 362 | resultDelta = this.resizePanel(panelIndex, delta, panels); 363 | } else { 364 | resultDelta = this.resizePanel(panelIndex + 1, -delta, panels); 365 | } 366 | } 367 | 368 | // Iterate through left panel's snap positions 369 | for (let i = 0; i < panels[panelIndex].snap.length; i++) { 370 | if (Math.abs(panels[panelIndex].snap[i] - panels[panelIndex].size) < 20) { 371 | delta = panels[panelIndex].snap[i] - panels[panelIndex].size; 372 | 373 | if ( 374 | delta !== 0 && 375 | panels[panelIndex].size + delta >= this.getPanelMinSize(panelIndex, panels) && 376 | panels[panelIndex + 1].size - delta >= this.getPanelMinSize(panelIndex + 1, panels) 377 | ) { 378 | resultDelta = this.resizePanel(panelIndex, delta, panels); 379 | } 380 | } 381 | } 382 | 383 | // Iterate through right panel's snap positions 384 | for (let i = 0; i < panels[panelIndex + 1].snap.length; i++) { 385 | if (Math.abs(panels[panelIndex + 1].snap[i] - panels[panelIndex + 1].size) < 20) { 386 | delta = panels[panelIndex + 1].snap[i] - panels[panelIndex + 1].size; 387 | 388 | if ( 389 | delta !== 0 && 390 | panels[panelIndex].size + delta >= this.getPanelMinSize(panelIndex, panels) && 391 | panels[panelIndex + 1].size - delta >= this.getPanelMinSize(panelIndex + 1, panels) 392 | ) { 393 | resultDelta = this.resizePanel(panelIndex, -delta, panels); 394 | } 395 | } 396 | } 397 | 398 | // return how much this panel actually resized 399 | return resultDelta; 400 | }; 401 | 402 | // Utility function for getting min pixel size of panel 403 | getPanelMinSize = (panelIndex, panels) => { 404 | if (panels[panelIndex].resize === 'fixed') { 405 | if (!panels[panelIndex].fixedSize) { 406 | panels[panelIndex].fixedSize = panels[panelIndex].size; 407 | } 408 | return panels[panelIndex].fixedSize; 409 | } 410 | return panels[panelIndex].minSize; 411 | }; 412 | 413 | // Utility function for getting max pixel size of panel 414 | getPanelMaxSize = (panelIndex, panels) => { 415 | if (panels[panelIndex].resize === 'fixed') { 416 | if (!panels[panelIndex].fixedSize) { 417 | panels[panelIndex].fixedSize = panels[panelIndex].size; 418 | } 419 | return panels[panelIndex].fixedSize; 420 | } 421 | return panels[panelIndex].maxSize; 422 | // return 0; 423 | }; 424 | 425 | // Utility function for getting min pixel size of the entire panel group 426 | getPanelGroupMinSize = (spacing) => { 427 | let size = 0; 428 | for (let i = 0; i < this.state.panels.length; i++) { 429 | size += this.getPanelMinSize(i, this.state.panels); 430 | } 431 | return size + (this.state.panels.length - 1) * spacing; 432 | }; 433 | 434 | // Utility function for getting max pixel size of the entire panel group 435 | getPanelGroupMaxSize = (spacing) => { 436 | let size = 0; 437 | for (let i = 0; i < this.state.panels.length; i++) { 438 | size += this.getPanelMaxSize(i, this.state.panels); 439 | } 440 | return size + (this.state.panels.length - 1) * spacing; 441 | }; 442 | 443 | // Hard-set a panel's size 444 | // Used to recalculate a stretchy panel when the window is resized 445 | setPanelSize = (panelIndex, size, callback, node) => { 446 | if (!this.node && node) { 447 | // due to timing child elements may have parent node first! 448 | this.node = node; 449 | } 450 | size = this.props.direction === 'column' ? size.y : size.x; 451 | if (size !== this.state.panels[panelIndex].size) { 452 | const tempPanels = this.state.panels.map(panel => ({ ...panel })); 453 | 454 | // make sure we can actually resize this panel this small 455 | if (size < tempPanels[panelIndex].minSize) { 456 | let diff = tempPanels[panelIndex].minSize - size; 457 | tempPanels[panelIndex].size = tempPanels[panelIndex].minSize; 458 | 459 | // 1) Find all of the dynamic panels that we can resize and 460 | // decrease them until the difference is gone 461 | for (let i = 0; i < tempPanels.length; i += 1) { 462 | if (i !== panelIndex && tempPanels[i].resize === 'dynamic') { 463 | const available = tempPanels[i].size - tempPanels[i].minSize; 464 | const cut = Math.min(diff, available); 465 | tempPanels[i].size -= cut; 466 | // if the difference is gone then we are done! 467 | diff -= cut; 468 | if (diff === 0) { 469 | break; 470 | } 471 | } 472 | } 473 | } else { 474 | tempPanels[panelIndex].size = size; 475 | } 476 | this.setState({ panels: tempPanels }); 477 | this.onUpdate(tempPanels); 478 | 479 | if (panelIndex > 0) { 480 | this.handleResize(panelIndex - 1, { x: 0, y: 0 }); 481 | } else if (this.state.panels.length > 2) { 482 | this.handleResize(panelIndex + 1, { x: 0, y: 0 }); 483 | } 484 | 485 | if (callback) { 486 | callback(); 487 | } 488 | } 489 | }; 490 | 491 | render() { 492 | const { children } = this.props; 493 | 494 | const style = this.getStyle(); 495 | 496 | // lets build up a new children array with added resize borders 497 | const initialChildren = React.Children.toArray(children); 498 | const newChildren = []; 499 | 500 | for (let i = 0; i < initialChildren.length; i++) { 501 | const panelStyle = this.getPanelStyle(i); 502 | const newPanel = this.createPanel({ panelStyle, index: i, initialChildren }); 503 | newChildren.push(newPanel); 504 | this.maybeDivide({ initialChildren, newChildren, index: i }); 505 | } 506 | 507 | return ( 508 |
{ 512 | this.node = node; 513 | }} 514 | > 515 | {newChildren} 516 |
517 | ); 518 | } 519 | } 520 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import PanelGroup from './PanelGroup'; 2 | 3 | export { default as Panel } from './Panel'; 4 | export { default as Divider } from './Divider'; 5 | export default PanelGroup; 6 | // ultimatley created this file due to 7 | // https://github.com/insin/nwb/issues/449 8 | -------------------------------------------------------------------------------- /tests/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tests/index-test.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | import React from 'react'; 3 | import { render, unmountComponentAtNode } from 'react-dom'; 4 | 5 | import Component from 'src'; 6 | 7 | describe('Component', () => { 8 | let node; 9 | 10 | beforeEach(() => { 11 | node = document.createElement('div'); 12 | }); 13 | 14 | afterEach(() => { 15 | unmountComponentAtNode(node); 16 | }); 17 | 18 | it('PanelGroup renders', () => { 19 | render(, node, () => { 20 | expect(node.innerHTML).toContain(''); 21 | }); 22 | }); 23 | }); 24 | --------------------------------------------------------------------------------