├── .babelrc ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .importjs.js ├── .travis.yml ├── CONTRIBUTING.md ├── README.md ├── package.json ├── src ├── __tests__ │ └── withAvailableWidth-test.jsx ├── test.css ├── test.html ├── test.jsx └── withAvailableWidth.jsx ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "transform-react-jsx", 4 | "add-module-exports" 5 | ], 6 | "presets": ["es2015"] 7 | } 8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'airbnb', 3 | parserOptions: { 4 | ecmaVersion: 6, 5 | sourceType: 'module', 6 | }, 7 | env: { 8 | jasmine: true, 9 | jest: true, 10 | browser: true, 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.importjs.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | importDevDependencies: true, 3 | excludes: [ 4 | './dist/**', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - '6' 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | 1. Install dependencies by running `yarn install` 4 | 2. Start watching for changes: `yarn run build-test -- --watch` 5 | 3. Make changes 6 | 4. View effect of changes by opening `src/test.html` in one or more browsers 7 | 8 | # Building the distributed package 9 | 10 | Run `yarn run build`. This will automatically happen during publishing. 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Truly responsive React components 2 | 3 | Using media queries to style components differently depending on the screen 4 | width is great if you're only working in a single column. But let's say you 5 | have a multi-column layout where you want responsive components based on the 6 | available width in the current container? Or you want a component to be able to 7 | render in a lot of different contexts, with unknown widths? With regular 8 | media-queries, you can't do that. 9 | 10 | `withAvailableWidth` is a 11 | [HOC](https://facebook.github.io/react/docs/higher-order-components.html) that 12 | will inject a `availableWidth` prop to the wrapped component. It will allow you 13 | to write components that render differently based on the currently available 14 | width. Here's an example -- a `ToggleButton` that collapses to a checkbox in 15 | narrow contexts. 16 | 17 | ```jsx 18 | function ToggleButton({ 19 | downLabel, 20 | upLabel, 21 | isDown, 22 | availableWidth, 23 | }) { 24 | if (availableWidth < 50) { 25 | return ; 26 | } 27 | return ( 28 |
29 | 30 | 31 |
32 | ); 33 | } 34 | export default withAvailableWidth(ToggleButton); 35 | ``` 36 | 37 | What's great here is that we can reuse this component in many contexts. If it's 38 | rendered in a table for instance, it's likely to render as a checkbox. But if 39 | it's a standalone component in a wide container, it's probably going to show 40 | the regular, wider version. 41 | 42 | ## Similar solutions 43 | 44 | [react-measure](https://github.com/souporserious/react-measure) is a great 45 | general tool for computing dimensions. But it suffers from having to render 46 | components twice in order to get the width and react to it. 47 | 48 | [react-measure-it](https://github.com/plusacht/react-measure-it) is also a HOC 49 | with roughly the same idea as `withAvailableWidth`. But it gives you the 50 | dimensions of the container, not the available width inside the container. 51 | 52 | ## How does it work? 53 | 54 | To figure out the available width in the current context, we drop an empty 55 | `
` in the DOM for a brief moment. As soon as the div is mounted, we 56 | measure its width, then re-render with the calculated width injected as 57 | `availableWidth` to the component. The component can then render things 58 | conditionally based on this number. 59 | 60 | ## Reacting to size changes 61 | 62 | By default, `withAvailableWidth` will only recalculate the width when the 63 | window is resized. If you need more fine-grained control, you can provide your 64 | own observer by passing in a function as the second argument to the HOC. Here's 65 | an example using [`ResizeObserver` 66 | ](https://github.com/que-etc/resize-observer-polyfill): 67 | 68 | ```jsx 69 | import ResizeObserver from 'resize-observer-polyfill'; 70 | 71 | export default withAvailableWidth( 72 | ToggleButton, 73 | (domElement, notify) => { 74 | const observer = new ResizeObserver(() => notify()); 75 | observer.observe(domElement); 76 | return () => observer.unobserve(domElement); 77 | } 78 | ); 79 | ``` 80 | 81 | The observer function is called once from the HOC, with two arguments: 82 | - `domElement`: the parent element of the wrapped component 83 | - `notify`: a function to call on every size change 84 | 85 | You need to return a function that will clean out the observer. This function 86 | will get called when the HOC is unmounted to clean up possible event listeners. 87 | 88 | ## Contributing 89 | 90 | See [CONTRIBUTING.md](CONTRIBUTING.md) for help on how to contribute. 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-with-available-width", 3 | "version": "2.1.0", 4 | "description": "A React HOC that injects an `availableWidth` prop to the wrapped component", 5 | "main": "dist/withAvailableWidth.js", 6 | "repository": "git@github.com:trotzig/with-available-width.git", 7 | "author": "Henric Trotzig ", 8 | "license": "MIT", 9 | "files": [ 10 | "dist" 11 | ], 12 | "scripts": { 13 | "clean": "rimraf dist", 14 | "build": "npm run clean && babel src --out-dir dist --ignore *test.*", 15 | "build-test": "webpack", 16 | "prepublish": "npm run build", 17 | "lint": "eslint . --ext .js,.jsx", 18 | "test": "npm run lint && jest" 19 | }, 20 | "jest": { 21 | "testRegex": "-test\\.jsx$" 22 | }, 23 | "dependencies": {}, 24 | "peerDependencies": { 25 | "react": ">= 16.0.0" 26 | }, 27 | "devDependencies": { 28 | "babel-cli": "^6.24.0", 29 | "babel-loader": "^6.4.1", 30 | "babel-plugin-add-module-exports": "^0.2.1", 31 | "babel-plugin-transform-react-jsx": "^6.23.0", 32 | "babel-preset-es2015": "^6.24.0", 33 | "eslint": "^3.19.0", 34 | "eslint-config-airbnb": "^14.1.0", 35 | "eslint-plugin-import": "^2.2.0", 36 | "eslint-plugin-jsx-a11y": "^4.0.0", 37 | "eslint-plugin-react": "^6.10.3", 38 | "jest": "^19.0.2", 39 | "react": "^16.2.0", 40 | "react-dom": "^16.2.0", 41 | "resize-observer-polyfill": "^1.4.1", 42 | "rimraf": "^2.6.1", 43 | "webpack": "^2.3.2" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/__tests__/withAvailableWidth-test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactTestUtils from 'react-dom/test-utils'; 3 | 4 | import withAvailableWidth from '../withAvailableWidth'; 5 | 6 | it('throws an error if observer does not return a method', () => { 7 | function render() { 8 | const Component = withAvailableWidth(() =>
, () => true); 9 | return ReactTestUtils.renderIntoDocument(); 10 | } 11 | expect(render).toThrowError(/The observer did not provide a way to unobserve/); 12 | }); 13 | -------------------------------------------------------------------------------- /src/test.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | .example { 6 | height: 30px; 7 | line-height: 30px; 8 | margin-bottom: 10px; 9 | text-align: center; 10 | background-color: #e1e1e1; 11 | border: 1px solid #ccc; 12 | } 13 | 14 | .example.comparison { 15 | background-color: #cbd9ff; 16 | border-color: #7e96d6; 17 | width: 100%; 18 | } 19 | 20 | .container { 21 | background-color: rgba(100, 100, 100, 0.05); 22 | max-width: 100%; 23 | } 24 | 25 | .relative-container { 26 | position: relative; 27 | width: 400px; 28 | max-width: 100%; 29 | height: 200px; 30 | } 31 | 32 | .absolute-child { 33 | position: absolute; 34 | bottom: 0; 35 | right: 0; 36 | width: 100%; 37 | max-width: 350px; 38 | padding: 10px; 39 | } 40 | 41 | .flexbox { 42 | display: flex; 43 | } 44 | 45 | .float-layout { 46 | overflow: hidden; 47 | } 48 | 49 | .float-sidebar { 50 | float: left; 51 | width: 25%; 52 | } 53 | .float-main { 54 | float: right; 55 | width: 75%; 56 | } 57 | 58 | .animated { 59 | transition: width 250ms; 60 | } 61 | -------------------------------------------------------------------------------- /src/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/test.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import PropTypes from 'prop-types'; 3 | import React, { PureComponent } from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import ResizeObserver from 'resize-observer-polyfill'; 6 | 7 | import withAvailableWidth from './withAvailableWidth'; 8 | 9 | function resizeObserver(domElement, notify) { 10 | const ro = new ResizeObserver(() => { 11 | console.log('Resizing happened', domElement); 12 | notify(); 13 | }); 14 | ro.observe(domElement); 15 | return () => ro.unobserve(domElement); 16 | } 17 | 18 | function RenderLessComponent({ availableWidth }) { 19 | console.log('RenderLessComponent availableWidth', availableWidth); 20 | return null; 21 | } 22 | 23 | class Component extends React.Component { 24 | componentDidMount() { 25 | console.log('Instance mounted'); 26 | } 27 | 28 | componentWillUnmount() { 29 | console.log('Instance unmounted'); 30 | } 31 | 32 | render() { 33 | const { availableWidth, height } = this.props; 34 | return ( 35 |
36 |
42 | w={availableWidth} 43 |
44 |
45 | ); 46 | } 47 | } 48 | Component.propTypes = { 49 | availableWidth: PropTypes.number.isRequired, 50 | height: PropTypes.number, 51 | }; 52 | 53 | Component.defaultProps = { 54 | height: undefined, 55 | }; 56 | 57 | const WrappedComponent = withAvailableWidth(Component); 58 | const WrappedComponentRO = withAvailableWidth(Component, resizeObserver); 59 | const WrappedRenderLessComponent = withAvailableWidth(RenderLessComponent); 60 | const WrappedRenderLessComponentRO = withAvailableWidth(RenderLessComponent, resizeObserver); 61 | 62 | function Comparison() { 63 | return ( 64 |
65 | [comparison] 66 |
67 | ); 68 | } 69 | 70 | // eslint-disable-next-line react/no-multi-comp 71 | class TestApp extends PureComponent { 72 | constructor() { 73 | super(); 74 | this.state = {}; 75 | } 76 | 77 | componentDidMount() { 78 | setTimeout(() => { 79 | this.setState({ 80 | delayDone: true, 81 | }); 82 | }, 100); 83 | } 84 | 85 | render() { 86 | return ( 87 |
88 |

Test page

89 |

90 | This page lists components wrapped in 91 | {' '} 92 | withAvailableWidth 93 | {' '} 94 | in a number of contexts. Each example is rendered twice. First, with a 95 | default resize observer (listening to resize events on 96 | the window object). Then with a 97 | {' '} 98 | 99 | ResizeObserver polyfill 100 | . After the examples, a comparison is rendered in light blue. 101 | The blue element is not using the HOC. 102 |

103 |
104 | 105 |

Full width

106 |

107 | This element should span the entire width of the document. 108 | Try resizing the window to make sure it updates. 109 |

110 | 111 | 112 | 113 |
114 | 115 |

In an absolutely positioned element

116 |

117 | The element is absolutely positioned at the bottom right edge. 118 | Try resizing to see how max-width comes into play. 119 |

120 |
121 |
122 | 123 | 124 | 125 |
126 |
127 |
128 | 129 |

In a float-based layout

130 |

131 |

132 |
133 | 134 | 135 | 136 |
137 |
138 | 139 | 140 | 141 |
142 |
143 |
144 | 145 | 146 |

In a balanced flexbox

147 |

148 | This element should take up as much space as its siblings. 149 |

150 |
151 | 152 | 153 | 154 |
155 |
156 | 157 | 158 | 159 |
160 |
161 | 162 |

In an unbalanced flexbox

163 |

164 | This element should take up as much space as possible. 165 |

166 |
167 |
168 | 169 |
170 |
171 | 172 |
173 | 174 |
175 |
176 |
177 | 178 |
179 |
180 | 181 |
182 | 183 |
184 |
185 | 186 |

In a table

187 |

188 | Browsers ignore the width of elements, and use content to size 189 | the different columns. The available width here is what an empty 190 | div would be assigned in the same context. 191 |

192 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 |
OneTwoThree
216 |
217 | 218 |

In a table with fixed layout

219 |

220 | All columns should have the same width. 221 |

222 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 |
OneTwoThree
246 |
247 | 248 |

When styling is applied asynchronously

249 |

250 | Some setups will render the DOM once without any css, then apply 251 | styling. Only the ResizeObserver powered element will 252 | work here. 253 |

254 |
258 | 259 | 260 | 261 |
262 |
263 | 264 |

In an animated container

265 |

266 | Only the ResizeObserver powered element will adjust its 267 | width here. 268 |

269 |
273 | 274 | 275 | 276 |
277 |
278 | 279 |

In a container with scroll

280 |

281 | The scroll position should be maintained when resizing the window. 282 |

283 |
287 | 288 |
289 |
290 | 291 |

Child without DOM elements

292 |

293 | Sometimes it is useful to have a child that does not render anything. 294 | It is important that these components don't throw errors. 295 |

296 |
297 | 298 | 299 |
300 |
301 |
302 | ); 303 | } 304 | } 305 | 306 | window.addEventListener('DOMContentLoaded', () => { 307 | const root = document.createElement('div'); 308 | document.body.appendChild(root); 309 | ReactDOM.render(, root); 310 | }); 311 | -------------------------------------------------------------------------------- /src/withAvailableWidth.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable comma-dangle, no-underscore-dangle */ 2 | import React, { PureComponent } from 'react'; 3 | 4 | function defaultObserver(_domElement, notify) { 5 | let lastKnownWidth = window ? window.innerWidth : undefined; 6 | const listener = (e) => { 7 | if (lastKnownWidth !== window.innerWidth) { 8 | notify(e); 9 | } 10 | lastKnownWidth = window.innerWidth; 11 | }; 12 | window.addEventListener('resize', listener, { passive: true }); 13 | return () => { 14 | window.removeEventListener('resize', listener, { passive: true }); 15 | }; 16 | } 17 | 18 | /** 19 | * HoC that injects a `availableWidth` prop to the component, equal to the 20 | * available width in the current context 21 | * 22 | * @param {Object} Component 23 | * @return {Object} a wrapped Component 24 | */ 25 | export default function withAvailableWidth( 26 | Component, 27 | observer = defaultObserver 28 | ) { 29 | return class extends PureComponent { 30 | constructor() { 31 | super(); 32 | this._instanceId = `waw-${Math.random().toString(36).substring(7)}`; 33 | this.state = { 34 | dirty: true, 35 | dirtyCount: 0, 36 | availableWidth: undefined, 37 | height: undefined, 38 | }; 39 | this._handleDivRef = this._handleDivRef.bind(this); 40 | } 41 | 42 | componentDidMount() { 43 | this._unobserve = observer(this._containerElement, () => { 44 | this.setState({ 45 | dirty: true, 46 | dirtyCount: this.state.dirtyCount + 1, 47 | height: this._element.nextSibling 48 | ? this._element.nextSibling.offsetHeight 49 | : undefined, 50 | }); 51 | }); 52 | if (typeof this._unobserve !== 'function') { 53 | throw new Error( 54 | 'The observer did not provide a way to unobserve. ' + 55 | 'This will likely lead to memory leaks.' 56 | ); 57 | } 58 | } 59 | 60 | componentWillUnmount() { 61 | this._unobserve(); 62 | } 63 | 64 | _handleDivRef(domElement) { 65 | if (!domElement) { 66 | return; 67 | } 68 | this._element = domElement; 69 | this._containerElement = domElement.parentNode; 70 | 71 | this.setState({ 72 | availableWidth: domElement.offsetWidth, 73 | dirty: false, 74 | }); 75 | } 76 | 77 | render() { 78 | const { availableWidth, dirty, dirtyCount, height } = this.state; 79 | 80 | return ( 81 | 82 | {dirty && ( 83 |