├── .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 |
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 |
--------------------------------------------------------------------------------