├── .npmignore ├── .gitignore ├── .babelrc ├── .travis.yml ├── LICENSE ├── package.json ├── readme.md ├── src └── equalizer.js └── __tests__ └── equalizer-test.js /.npmignore: -------------------------------------------------------------------------------- 1 | /src/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /lib/ 2 | /node_modules/ -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"], 3 | "plugins": ["transform-object-rest-spread"] 4 | } 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "4" 5 | - "5" 6 | 7 | branches: 8 | only: 9 | - master 10 | 11 | # Use container-based Travis infrastructure 12 | sudo: false 13 | 14 | cache: 15 | directories: 16 | - node_modules 17 | 18 | install: 19 | - npm install 20 | # Check versions 21 | - node --version 22 | - npm --version 23 | 24 | script: 25 | - npm test -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Patrick Galbraith 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-equalizer", 3 | "version": "1.3.0", 4 | "description": "Pure React Match Height Component", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/patrickgalbraith/react-equalizer.git" 8 | }, 9 | "keywords": [ 10 | "react", 11 | "equalizer", 12 | "equalheight", 13 | "matchheight" 14 | ], 15 | "author": "Patrick Galbraith", 16 | "homepage": "https://github.com/patrickgalbraith/react-equalizer", 17 | "bugs": { 18 | "url": "https://github.com/patrickgalbraith/react-equalizer/issues" 19 | }, 20 | "license": "MIT", 21 | "main": "./lib/equalizer.js", 22 | "scripts": { 23 | "test": "jest", 24 | "prepublish": "node ./node_modules/babel-cli/bin/babel.js src --out-dir lib" 25 | }, 26 | "jest": { 27 | "scriptPreprocessor": "/node_modules/babel-jest", 28 | "unmockedModulePathPatterns": [ 29 | "react", 30 | "react-dom", 31 | "react-addons-test-utils", 32 | "fbjs" 33 | ] 34 | }, 35 | "peerDependencies": { 36 | "react": ">0.14.0", 37 | "react-dom": ">0.14.0" 38 | }, 39 | "devDependencies": { 40 | "babel-cli": "^6.5.1", 41 | "babel-core": "^6.5.1", 42 | "babel-jest": "^6.0.1", 43 | "babel-plugin-transform-object-rest-spread": "^6.22.0", 44 | "babel-preset-es2015": "^6.5.0", 45 | "babel-preset-react": "^6.5.0", 46 | "jest-cli": "^0.8.2", 47 | "react": "^0.14.0", 48 | "react-addons-test-utils": "^0.14.7", 49 | "react-dom": "^0.14.0" 50 | }, 51 | "dependencies": { 52 | "prop-types": "^15.5.10" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ![Build Status](https://api.travis-ci.org/patrickgalbraith/react-equalizer.svg) 2 | 3 | # React Equalizer 4 | 5 | Pure React component which equalizes heights of components. 6 | 7 | ## Installation 8 | 9 | ``` 10 | npm install --save react-equalizer 11 | ``` 12 | 13 | ## Usage 14 | 15 | This is a basic example which equalizes height of child components. 16 | 17 | ```jsx 18 | 19 |
Child 1
20 |
Child 2
21 |
Child 3
22 |
23 | ``` 24 | 25 | ### Options 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 40 | 43 | 46 | 47 | 48 | 51 | 54 | 57 | 58 | 59 | 62 | 65 | 68 | 69 | 70 | 73 | 77 | 80 | 81 | 82 |
PropDefaultDescription
38 | property 39 | 41 | height 42 | 44 | The style property used when setting height. Usually height, maxHeight or minHeight. 45 |
49 | byRow 50 | 52 | true 53 | 55 | By default Equalizer will attempt to take into account stacking by matching rows by their window offset. 56 |
60 | enabled 61 | 63 | (component, node) => true 64 | 66 | Takes a function that returns true or false and can be used to disable Equalizer. Useful if you want to disable Equalizer when something changes such as window width or height based on a media query.. 67 |
71 | nodes 72 | 74 |
(component, node) =>
 75 |   node.children
76 |
78 | Function which returns nodes to equalize. By default Equalizer only measures the heights of its direct descendants. 79 |
83 | 84 | ### Simple example with options 85 | 86 | ```jsx 87 | window.matchMedia("(min-width: 400px)").matches}> 91 |
Child 1
92 |
Child 2
93 |
Child 3
94 |
95 | ``` 96 | 97 | ### Specifying nodes example 98 | 99 | This can be useful if you want to equalize components other than direct descendants. 100 | 101 | ```jsx 102 | class MyComponent extends Component { 103 | getNodes(equalizerComponent, equalizerElement) { 104 | return [ 105 | this.refs.node1, 106 | this.refs.node2, 107 | this.refs.node3 108 | ] 109 | } 110 | 111 | render() { 112 | return( 113 | this.getNodes()}> 114 |
this.node1 = n}>
115 |
116 |
this.node2 = n}>
117 |
118 |
this.node3 = n}>
119 |
120 | ) 121 | } 122 | } 123 | ``` 124 | 125 | ### Demo 126 | 127 | http://jsbin.com/ceyumumuye/edit?js,output 128 | 129 | ## Running Tests 130 | 131 | Grab the latest source and in the project directory run: 132 | 133 | ``` 134 | npm install 135 | npm test 136 | ``` 137 | 138 | ## Roadmap 139 | 140 | * Add support for setting height of Equalizer component based on total height of children. This will be useful if children are positioned absolutely and the container needs to have a fixed height. 141 | 142 | ## References 143 | * Zurb Foundation Equalizer 144 | * jQuery Match Height 145 | -------------------------------------------------------------------------------- /src/equalizer.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | export default class Equalizer extends Component { 5 | constructor() { 6 | super() 7 | this.handleResize = debounce(this.handleResize.bind(this), 50) 8 | this.updateChildrenHeights = this.updateChildrenHeights.bind(this) 9 | } 10 | 11 | componentDidMount() { 12 | this.handleResize() 13 | addEventListener('resize', this.handleResize) 14 | } 15 | 16 | componentWillUnmount() { 17 | this.rootNode = null 18 | this.handleResize.clear() 19 | removeEventListener('resize', this.handleResize) 20 | } 21 | 22 | componentDidUpdate() { 23 | this.handleResize() 24 | } 25 | 26 | handleResize() { 27 | setTimeout(this.updateChildrenHeights, 0) 28 | } 29 | 30 | static getHeights(nodes, byRow = true) { 31 | let lastElTopOffset = 0, 32 | groups = [], 33 | row = 0 34 | 35 | groups[row] = [] 36 | 37 | for(let i = 0; i < nodes.length; i++){ 38 | let node = nodes[i] 39 | 40 | node.style.height = 'auto' 41 | node.style.maxHeight = '' 42 | node.style.minHeight = '' 43 | 44 | // http://ejohn.org/blog/getboundingclientrect-is-awesome/ 45 | const {top: elOffsetTop, height: elHeight} = node.getBoundingClientRect() 46 | 47 | if(i === 0) { 48 | lastElTopOffset = elOffsetTop 49 | } 50 | 51 | if (elOffsetTop != lastElTopOffset && byRow) { 52 | row++ 53 | groups[row] = [] 54 | lastElTopOffset = elOffsetTop 55 | } 56 | 57 | groups[row].push([node, elHeight]) 58 | } 59 | 60 | for (let j = 0; j < groups.length; j++) { 61 | const heights = groups[j].map((item) => item[1]) 62 | const max = Math.max.apply(null, heights) 63 | groups[j].push(max) 64 | } 65 | 66 | return groups 67 | } 68 | 69 | updateChildrenHeights() { 70 | const { property, byRow, enabled, nodeWillCompute, nodeWillUpdate } = this.props 71 | const node = this.rootNode 72 | 73 | if (!node || !enabled(this, node)) { 74 | return 75 | } 76 | 77 | if (node !== undefined) { 78 | const children = this.props.nodes(this, node) 79 | const childrenToCompute = Array.from(children).filter(nodeWillCompute) 80 | const heights = this.constructor.getHeights(childrenToCompute, byRow) 81 | 82 | for (let row = 0; row < heights.length; row++) { 83 | const max = heights[row][heights[row].length-1] 84 | 85 | for (let i = 0; i < (heights[row].length - 1); i++) { 86 | if (nodeWillUpdate(heights[row][i][0], heights[row][i][1])) { 87 | heights[row][i][0].style[property] = max + 'px' 88 | } 89 | } 90 | } 91 | } 92 | } 93 | 94 | render() { 95 | const {children, property, byRow, enabled, nodes, ...otherProps} = this.props 96 | return ( 97 |
this.rootNode = node} onLoad={this.handleResize} {...otherProps}> 98 | {children} 99 |
100 | ) 101 | } 102 | } 103 | 104 | Equalizer.defaultProps = { 105 | property: 'height', 106 | byRow: true, 107 | enabled: () => true, 108 | nodeWillCompute: () => true, 109 | nodeWillUpdate: () => true, 110 | nodes: (component, node) => node.children 111 | } 112 | 113 | Equalizer.propTypes = { 114 | children: PropTypes.node.isRequired, 115 | property: PropTypes.string, 116 | byRow: PropTypes.bool, 117 | enabled: PropTypes.func, 118 | nodes: PropTypes.func 119 | } 120 | 121 | // from: https://github.com/component/debounce 122 | function debounce(func, wait, immediate){ 123 | var timeout, args, context, timestamp, result 124 | if (null == wait) wait = 100 125 | 126 | function later() { 127 | var last = Date.now() - timestamp 128 | 129 | if (last < wait && last >= 0) { 130 | timeout = setTimeout(later, wait - last) 131 | } else { 132 | timeout = null 133 | if (!immediate) { 134 | result = func.apply(context, args) 135 | context = args = null 136 | } 137 | } 138 | } 139 | 140 | var debounced = function(){ 141 | context = this 142 | args = arguments 143 | timestamp = Date.now() 144 | var callNow = immediate && !timeout 145 | if (!timeout) timeout = setTimeout(later, wait) 146 | if (callNow) { 147 | result = func.apply(context, args) 148 | context = args = null 149 | } 150 | 151 | return result 152 | } 153 | 154 | debounced.clear = function() { 155 | if (timeout) { 156 | clearTimeout(timeout) 157 | timeout = null 158 | } 159 | } 160 | 161 | return debounced 162 | } -------------------------------------------------------------------------------- /__tests__/equalizer-test.js: -------------------------------------------------------------------------------- 1 | jest.dontMock('../src/equalizer'); 2 | 3 | const React = require('react'); 4 | const ReactDOM = require('react-dom'); 5 | const TestUtils = require('react-addons-test-utils'); 6 | 7 | const Equalizer = require('../src/equalizer').default; 8 | 9 | const getMaxHeight = (heights) => heights[heights.length-1] 10 | 11 | describe('Equalizer', () => { 12 | let inlineNodes, stackedNodes; 13 | 14 | beforeEach(function() { 15 | inlineNodes = [ 16 | { 17 | getBoundingClientRect: () => ({ 18 | top: 0, 19 | height: 50 20 | }), 21 | style: {} 22 | }, 23 | { 24 | getBoundingClientRect: () => ({ 25 | top: 0, 26 | height: 150 27 | }), 28 | style: {} 29 | }, 30 | { 31 | getBoundingClientRect: () => ({ 32 | top: 0, 33 | height: 100 34 | }), 35 | style: {} 36 | } 37 | ] 38 | 39 | stackedNodes = [ 40 | { 41 | getBoundingClientRect: () => ({ 42 | top: 0, 43 | height: 50 44 | }), 45 | style: {} 46 | }, 47 | { 48 | getBoundingClientRect: () => ({ 49 | top: 0, 50 | height: 100 51 | }), 52 | style: {} 53 | }, 54 | { 55 | getBoundingClientRect: () => ({ 56 | top: 200, 57 | height: 125 58 | }), 59 | style: {} 60 | }, 61 | { 62 | getBoundingClientRect: () => ({ 63 | top: 200, 64 | height: 50 65 | }), 66 | style: {} 67 | } 68 | ] 69 | }) 70 | 71 | describe('.getHeights', function() { 72 | it('resets height for all nodes for recalculation', () => { 73 | let result = Equalizer.getHeights(inlineNodes) 74 | 75 | result.forEach((row) => { 76 | row.forEach((item) => { 77 | if(Array.isArray(item)) { 78 | expect(item[0].style.height).toEqual('auto') 79 | expect(item[0].style.maxHeight).toEqual('') 80 | expect(item[0].style.minHeight).toEqual('') 81 | } 82 | }) 83 | }) 84 | }) 85 | 86 | it('calculates max height for inline nodes', () => { 87 | // byRow = true 88 | let result = Equalizer.getHeights(inlineNodes, true) 89 | expect(result.length).toEqual(1) 90 | expect(result[0].length).toEqual(4) 91 | expect(getMaxHeight(result[0])).toEqual(150) 92 | }) 93 | 94 | it('calculates max height for inline nodes when byrow is disabled', () => { 95 | // byRow = false 96 | let result = Equalizer.getHeights(inlineNodes, false) 97 | expect(result.length).toEqual(1) 98 | expect(result[0].length).toEqual(4) 99 | expect(getMaxHeight(result[0])).toEqual(150) 100 | }) 101 | 102 | it('calculates max height for stacked nodes', () => { 103 | // byRow = true 104 | let result = Equalizer.getHeights(stackedNodes, true) 105 | 106 | expect(result.length).toEqual(2) 107 | 108 | // Row 1 109 | expect(result[0].length).toEqual(3) 110 | expect(getMaxHeight(result[0])).toEqual(100) 111 | 112 | // Row 2 113 | expect(result[1].length).toEqual(3) 114 | expect(getMaxHeight(result[1])).toEqual(125) 115 | }) 116 | 117 | it('calculates max height for stacked nodes when byrow is disabled', () => { 118 | // byRow = false 119 | let result = Equalizer.getHeights(stackedNodes, false) 120 | 121 | expect(result.length).toEqual(1) 122 | expect(result[0].length).toEqual(5) 123 | expect(getMaxHeight(result[0])).toEqual(125) 124 | }) 125 | }) 126 | 127 | describe('component', function() { 128 | it('sets children heights to the tallest child', (done) => { 129 | spyOn(Equalizer, 'getHeights').andCallFake(() => { 130 | return [[ 131 | [el.children[0], 0], 132 | [el.children[1], 150], 133 | [el.children[2], 0], 134 | 150 135 | ]] 136 | }) 137 | 138 | let component = TestUtils.renderIntoDocument( 139 | 140 |
141 |
142 |
143 |
144 | ) 145 | 146 | let el = ReactDOM.findDOMNode(component) 147 | 148 | jest.runAllTimers() 149 | 150 | expect(Equalizer.getHeights).toHaveBeenCalled() 151 | 152 | for (var i=0; i < el.children.length; i++) { 153 | var childNode = el.children[i] 154 | expect(childNode.style.height).toEqual('150px') 155 | } 156 | }) 157 | 158 | it('sets children heights to the tallest child in the same row', (done) => { 159 | spyOn(Equalizer, 'getHeights').andCallFake(() => { 160 | return [ 161 | [ 162 | [el.children[0], 75], 163 | [el.children[1], 100], 164 | 100 165 | ], 166 | [ 167 | [el.children[2], 50], 168 | [el.children[3], 125], 169 | 125 170 | ] 171 | ] 172 | }) 173 | 174 | let component = TestUtils.renderIntoDocument( 175 | 176 |
177 |
178 |
179 |
180 |
181 | ) 182 | 183 | let el = ReactDOM.findDOMNode(component) 184 | 185 | jest.runAllTimers() 186 | 187 | expect(Equalizer.getHeights).toHaveBeenCalled() 188 | 189 | for (var i=0; i < el.children.length; i++) { 190 | var childNode = el.children[i] 191 | 192 | if(i < 2) { 193 | expect(childNode.style.height).toEqual('100px') 194 | } else { 195 | expect(childNode.style.height).toEqual('125px') 196 | } 197 | } 198 | }) 199 | 200 | it('sets children minheights to the tallest child through custom property', (done) => { 201 | spyOn(Equalizer, 'getHeights').andCallFake(() => { 202 | return [[ 203 | [el.children[0], 0], 204 | [el.children[1], 150], 205 | [el.children[2], 0], 206 | 150 207 | ]] 208 | }) 209 | 210 | let component = TestUtils.renderIntoDocument( 211 | 212 |
213 |
214 |
215 |
216 | ) 217 | 218 | let el = ReactDOM.findDOMNode(component) 219 | 220 | jest.runAllTimers() 221 | 222 | expect(Equalizer.getHeights).toHaveBeenCalled() 223 | 224 | for (var i=0; i < el.children.length; i++) { 225 | var childNode = el.children[i] 226 | expect(childNode.style.minHeight).toEqual('150px') 227 | } 228 | }) 229 | 230 | it('allows setting specific nodes to the tallest node by ref', (done) => { 231 | let allNodes = null 232 | 233 | const TestComponent = React.createClass({ 234 | getNodes() { 235 | allNodes = [ 236 | this.refs.node1, 237 | this.refs.node2, 238 | this.refs.node3, 239 | this.refs.node4 240 | ] 241 | return allNodes 242 | }, 243 | 244 | render() { 245 | return( 246 | 247 |
248 |
249 |
250 |
251 |
252 |
253 | ) 254 | } 255 | }) 256 | 257 | spyOn(Equalizer, 'getHeights').andCallFake(() => { 258 | return [[ 259 | [ReactDOM.findDOMNode(allNodes[0]), 0], 260 | [ReactDOM.findDOMNode(allNodes[2]), 150], 261 | [ReactDOM.findDOMNode(allNodes[3]), 0], 262 | 150 263 | ]] 264 | }) 265 | 266 | let component = TestUtils.renderIntoDocument() 267 | let el = ReactDOM.findDOMNode(component) 268 | 269 | jest.runAllTimers() 270 | 271 | expect(Equalizer.getHeights).toHaveBeenCalled() 272 | 273 | expect(ReactDOM.findDOMNode(allNodes[0]).style.height).toEqual('150px') 274 | expect(ReactDOM.findDOMNode(allNodes[1]).style.height).not.toEqual('150px') 275 | expect(ReactDOM.findDOMNode(allNodes[2]).style.height).toEqual('150px') 276 | expect(ReactDOM.findDOMNode(allNodes[3]).style.height).toEqual('150px') 277 | }) 278 | 279 | it('can be disabled', (done) => { 280 | spyOn(Equalizer, 'getHeights') 281 | 282 | let component = TestUtils.renderIntoDocument( 283 | false}> 284 |
285 |
286 | ) 287 | 288 | let el = ReactDOM.findDOMNode(component) 289 | 290 | jest.runAllTimers() 291 | 292 | expect(Equalizer.getHeights).not.toHaveBeenCalled() 293 | }) 294 | }) 295 | }) --------------------------------------------------------------------------------