├── .babelrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── README.md ├── demo ├── bundle.js ├── entry.js └── index.html ├── package.json ├── src └── MediaContext.js ├── test └── MediaContext.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "stage-0", 5 | "react" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jxnblk/react-media-context/5b8e8ae37bb4dc36c23aaf93ac0fca7534289df3/.npmignore -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 6.2 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # react-media-context 3 | 4 | [![Build Status](https://travis-ci.org/jxnblk/react-media-context.svg?branch=master)](https://travis-ci.org/jxnblk/react-media-context) 5 | 6 | React higher-order component (HOC) to provide context for the currently matched media query. 7 | 8 | ## Getting Started 9 | 10 | ```sh 11 | npm i -S react-media-context 12 | ``` 13 | 14 | ## Usage 15 | 16 | ```js 17 | import React from 'react' 18 | import MediaContext from 'react-media-context' 19 | 20 | const Title = (props, context) => { 21 | const { media } = context 22 | let fontSize = 32 23 | if (media.indexOf('large') > -1) { 24 | fontSize = 64 25 | } 26 | 27 | const style = { 28 | fontSize 29 | } 30 | 31 | return ( 32 |

33 | Responsive Heading 34 |

35 | ) 36 | } 37 | 38 | Title.contextTypes = { 39 | media: React.PropTypes.array 40 | } 41 | 42 | class App extends React.Component { 43 | render () { 44 | 45 | 46 | </MediaContext> 47 | } 48 | } 49 | ``` 50 | 51 | ## How is this different from *x*? 52 | 53 | Most other responsive React HOCs tend to work on the principle of *showing and hiding* children based on media queries. With this component, you can alter styling and functionality of components responsively. 54 | 55 | This could be handy for: 56 | - Changing font sizes based on the viewport width 57 | - Changing margin or padding 58 | - Grid systems 59 | - Dramatically altering page layout 60 | - Using different routing flows 61 | - Changing image sources based on pixel density (but, srcset is probably a better option) 62 | 63 | ## Props 64 | 65 | - `queries` (Object) - Media queries to match against on window resize 66 | 67 | ```js 68 | // Default 69 | { 70 | 'xsmall': 'screen and (max-width: 40em)', 71 | 'small': 'screen and (min-width: 40em)', 72 | 'medium': 'screen and (min-width: 52em)', 73 | 'large': 'screen and (min-width: 64em)' 74 | } 75 | ``` 76 | 77 | MIT License 78 | 79 | -------------------------------------------------------------------------------- /demo/entry.js: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import ReactDOM from 'react-dom' 4 | import MediaContext from '../src/MediaContext' 5 | import readme from 'html!markdown!../README.md' 6 | 7 | const div = document.getElementById('app') 8 | 9 | const Header = ({}, { media }) => { 10 | let fontSize = 32 11 | 12 | if (media.indexOf('large') > -1) { 13 | fontSize = 96 14 | } else if (media.indexOf('medium') > -1) { 15 | fontSize = 64 16 | } 17 | 18 | const sx = { 19 | root: { 20 | padding: media.indexOf('xsmall') > -1 ? 16 : 32 21 | }, 22 | heading: { 23 | fontSize, 24 | margin: 0 25 | } 26 | } 27 | 28 | return ( 29 | <header style={sx.root}> 30 | <h1 style={sx.heading}> 31 | Hello 32 | </h1> 33 | <p> 34 | {'<MediaContext /> provider'} 35 | </p> 36 | <a href='//github.com/jxnblk/react-media-context'>GitHub</a> 37 | </header> 38 | ) 39 | } 40 | 41 | const Readme = (props, { media }) => { 42 | const sx = { 43 | maxWidth: '48em', 44 | padding: media.indexOf('xsmall') > -1 ? 16 : 32 45 | } 46 | 47 | return ( 48 | <div 49 | style={sx} 50 | dangerouslySetInnerHTML={{ __html: readme }} /> 51 | ) 52 | } 53 | 54 | Readme.contextTypes = { 55 | media: React.PropTypes.array 56 | } 57 | 58 | Header.contextTypes = { 59 | media: React.PropTypes.array 60 | } 61 | 62 | class App extends React.Component { 63 | render () { 64 | return ( 65 | <MediaContext> 66 | <Header /> 67 | <Readme /> 68 | </MediaContext> 69 | ) 70 | } 71 | } 72 | 73 | ReactDOM.render(<App />, div) 74 | 75 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <meta name='viewport' content='width=device-width, initial-scale=1'> 3 | <style> 4 | body { 5 | font-family: -apple-system, sans-serif; 6 | margin: 0; 7 | padding: 2rem; 8 | } 9 | </style> 10 | <div id='app'></div> 11 | <script src='bundle.js'></script> 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-media-context", 3 | "version": "1.0.0", 4 | "description": "React component to provide media query matches in context", 5 | "main": "dist/MediaContext.js", 6 | "scripts": { 7 | "prepublish": "mkdir -p dist && babel src --out-dir dist", 8 | "demo": "webpack -p", 9 | "start": "webpack-dev-server", 10 | "gh-pages": "gh-pages -d demo", 11 | "test": "mocha test --compilers js:babel-register" 12 | }, 13 | "keywords": [ 14 | "react", 15 | "react-component", 16 | "media-queries", 17 | "match-media", 18 | "responsive" 19 | ], 20 | "author": "Brent Jackson", 21 | "license": "MIT", 22 | "peerDependencies": { 23 | "react": "^15.1.0" 24 | }, 25 | "devDependencies": { 26 | "babel-cli": "^6.10.1", 27 | "babel-loader": "^6.2.4", 28 | "babel-preset-es2015": "^6.9.0", 29 | "babel-preset-react": "^6.5.0", 30 | "babel-preset-stage-0": "^6.5.0", 31 | "babel-register": "^6.9.0", 32 | "enzyme": "^2.3.0", 33 | "expect": "^1.20.1", 34 | "gh-pages": "^0.11.0", 35 | "html-loader": "^0.4.3", 36 | "markdown-loader": "^0.1.7", 37 | "mocha": "^2.5.3", 38 | "mocha-jsdom": "^1.1.0", 39 | "react": "^15.1.0", 40 | "react-addons-test-utils": "^15.1.0", 41 | "react-dom": "^15.1.0", 42 | "webpack": "^1.13.1", 43 | "webpack-dev-server": "^1.14.1" 44 | }, 45 | "dependencies": { 46 | "lodash": "^4.13.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/MediaContext.js: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import { debounce } from 'lodash' 4 | 5 | class MediaContext extends React.Component { 6 | constructor () { 7 | super() 8 | this.state = { 9 | media: [] 10 | } 11 | this.match = this.match.bind(this) 12 | this.handleResize = debounce(this.handleResize.bind(this), 100) 13 | } 14 | 15 | getChildContext () { 16 | return this.state 17 | } 18 | 19 | match () { 20 | const { queries } = this.props 21 | const media = [] 22 | for (var key in queries) { 23 | const { matches } = window.matchMedia(queries[key]) 24 | if (matches) { 25 | media.push(key) 26 | } 27 | } 28 | this.setState({ media }) 29 | } 30 | 31 | handleResize () { 32 | this.match() 33 | } 34 | 35 | componentDidMount () { 36 | this.match() 37 | window.addEventListener('resize', this.handleResize) 38 | } 39 | 40 | render () { 41 | return ( 42 | <div> 43 | {this.props.children} 44 | </div> 45 | ) 46 | } 47 | } 48 | 49 | MediaContext.childContextTypes = { 50 | media: React.PropTypes.array 51 | } 52 | 53 | MediaContext.propTypes = { 54 | queries: React.PropTypes.object 55 | } 56 | 57 | MediaContext.defaultProps = { 58 | queries: { 59 | 'xsmall': 'screen and (max-width: 40em)', 60 | 'small': 'screen and (min-width: 40em)', 61 | 'medium': 'screen and (min-width: 52em)', 62 | 'large': 'screen and (min-width: 64em)' 63 | } 64 | } 65 | 66 | export default MediaContext 67 | 68 | -------------------------------------------------------------------------------- /test/MediaContext.js: -------------------------------------------------------------------------------- 1 | 2 | require('mocha-jsdom')() 3 | 4 | import React from 'react' 5 | import ReactDOMServer from 'react-dom/server' 6 | import expect from 'expect' 7 | import { mount } from 'enzyme' 8 | import MediaContext from '../src/MediaContext' 9 | 10 | describe('<MediaContext />', () => { 11 | let tree 12 | 13 | before(() => { 14 | // Stub matchMedia 15 | window.matchMedia = () => { 16 | return { 17 | matches: false 18 | } 19 | } 20 | }) 21 | 22 | it('should render', () => { 23 | expect(() => { 24 | tree = mount(<MediaContext />) 25 | }).toNotThrow() 26 | }) 27 | 28 | it('should have a default queries prop', () => { 29 | expect(tree.props().queries).toBeAn('object') 30 | }) 31 | 32 | it('should have child context', () => { 33 | expect(tree.node.getChildContext().media).toEqual([]) 34 | }) 35 | 36 | context('when xsmall matches', () => { 37 | before(() => { 38 | window.matchMedia = (query) => { 39 | return { 40 | matches: /max\-width:\s40em\)$/.test(query) 41 | } 42 | } 43 | tree = mount(<MediaContext />) 44 | }) 45 | 46 | it('should include xsmall in the media context', () => { 47 | const { media } = tree.node.getChildContext() 48 | expect(media).toEqual(['xsmall']) 49 | }) 50 | }) 51 | 52 | context('when small matches', () => { 53 | before(() => { 54 | window.matchMedia = (query) => { 55 | return { 56 | matches: /min\-width:\s40em\)$/.test(query) 57 | } 58 | } 59 | tree = mount(<MediaContext />) 60 | }) 61 | 62 | it('should include small in the media context', () => { 63 | const { media } = tree.node.getChildContext() 64 | expect(media).toEqual(['small']) 65 | }) 66 | }) 67 | 68 | context('when medium matches', () => { 69 | before(() => { 70 | window.matchMedia = (query) => { 71 | return { 72 | matches: /min\-width:\s52em\)$/.test(query) 73 | } 74 | } 75 | tree = mount(<MediaContext />) 76 | }) 77 | 78 | it('should include medium in the media context', () => { 79 | const { media } = tree.node.getChildContext() 80 | expect(media).toEqual(['medium']) 81 | }) 82 | }) 83 | 84 | context('when large matches', () => { 85 | before(() => { 86 | window.matchMedia = (query) => { 87 | return { 88 | matches: /min\-width:\s64em\)$/.test(query) 89 | } 90 | } 91 | tree = mount(<MediaContext />) 92 | }) 93 | 94 | it('should include large in the media context', () => { 95 | const { media } = tree.node.getChildContext() 96 | expect(media).toEqual(['large']) 97 | }) 98 | }) 99 | 100 | context('when setting custom queries', () => { 101 | before(() => { 102 | window.matchMedia = (query) => { 103 | return { 104 | matches: /min\-width:\s640px\)$/.test(query) 105 | } 106 | } 107 | 108 | tree = mount( 109 | <MediaContext 110 | queries={{ 111 | 'mobile': 'screen and (max-width: 640px)', 112 | 'desktop': 'screen and (min-width: 640px)' 113 | }} /> 114 | ) 115 | }) 116 | 117 | it('should include custom media queries in context', () => { 118 | const { media } = tree.node.getChildContext() 119 | expect(media).toEqual(['desktop']) 120 | }) 121 | }) 122 | 123 | context('when resizing the window', () => { 124 | let match 125 | let handleResize 126 | 127 | before(() => { 128 | window.matchMedia = (query) => { 129 | return { matches: false } 130 | } 131 | match = expect.spyOn(MediaContext.prototype, 'match') 132 | handleResize = expect.spyOn(MediaContext.prototype, 'handleResize') 133 | tree = mount(<MediaContext />) 134 | const e = new Event('resize') 135 | window.dispatchEvent(e) 136 | }) 137 | 138 | it('should call the handleResize method', () => { 139 | expect(handleResize.call.length).toEqual(1) 140 | }) 141 | 142 | it('should call the match method', () => { 143 | expect(match.calls.length).toEqual(1) 144 | }) 145 | }) 146 | 147 | context('when rendering server-side', () => { 148 | it('should render', () => { 149 | expect(() => { 150 | const html = ReactDOMServer.renderToString(<MediaContext />) 151 | }).toNotThrow() 152 | }) 153 | }) 154 | }) 155 | 156 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 2 | const config = { 3 | entry: './demo/entry.js', 4 | output: { 5 | path: __dirname + '/demo', 6 | filename: 'bundle.js' 7 | }, 8 | module: { 9 | loaders: [ 10 | { test: /\.js$/, exclude: /node_modules/, loader: 'babel' } 11 | ] 12 | }, 13 | devServer: { 14 | contentBase: 'demo' 15 | } 16 | } 17 | 18 | module.exports = config 19 | 20 | --------------------------------------------------------------------------------