UNPKG

kitchensink

Version:

Dispatch's awesome components and style guide

748 lines (590 loc) 22.8 kB
import React from 'react'; import MouseUpListener from 'plugins/mouse-up-listener.js'; import objectAssign from 'object-assign'; const resolveStyles = require('inject!resolve-styles.js')({ 'exenv': require('__mocks__/exenv.js'), './prefixer': require('__mocks__/prefixer.js') }); const genComponent = function() { return { setState: sinon.spy(function(newState) { objectAssign(this.state, newState); }), state: {}, _radiumIsMounted: true }; }; // http://stackoverflow.com/a/25395068/13932 const permutate = function(arr) { const permutations = []; if (arr.length === 1) { return [arr]; } for (let i = 0; i < arr.length; i++) { const subPerms = permutate(arr.slice(0, i).concat(arr.slice(i + 1))); for (let j = 0; j < subPerms.length; j++) { subPerms[j].unshift(arr[i]); permutations.push(subPerms[j]); } } return permutations; }; const getChildrenArray = function(children) { const childrenArray = []; React.Children.forEach(children, function(child) { childrenArray.push(child); }); return childrenArray; }; describe('resolveStyles', function() { beforeEach(() => { MouseUpListener.subscribe = sinon.spy(); }); describe('no-op behavior', function() { it('handles null rendered element', function() { const component = genComponent(); resolveStyles(component, null); }); it('doesn\'t explode', function() { const component = genComponent(); const renderedElement = <div />; const result = resolveStyles(component, renderedElement); expect(result).to.equal(renderedElement); expect(result.props).to.equal(renderedElement.props); }); it('passes through normal style objects', function() { const component = genComponent(); const renderedElement = <div style={{color: 'blue'}} />; const result = resolveStyles(component, renderedElement); expect(result.props.style).to.deep.equal(renderedElement.props.style); }); it('passes through normal style objects of children', function() { const component = genComponent(); const style = {color: 'blue'}; const renderedElement = ( <div> <div style={style} /> </div> ); const result = resolveStyles(component, renderedElement); const children = getChildrenArray(result.props.children); expect(children[0].props.style).to.deep.equal(style); }); it('doesn\'t wrap string children in spans', function() { const component = genComponent(); const renderedElement = <div>Hello</div>; const result = resolveStyles(component, renderedElement); expect(result.props.children).to.equal('Hello'); }); it('doesn\'t wrap number children in spans', function() { const component = genComponent(); const renderedElement = <div>{88347}</div>; const result = resolveStyles(component, renderedElement); expect(result.props.children).to.equal(88347); }); it('ignores invalid children', function() { const component = genComponent(); // JSX won't let this through, so do it with a plain object instead const renderedElement = {props: { children: [null] }}; const result = resolveStyles(component, renderedElement); const children = getChildrenArray(result.props.children); expect(children[0]).to.be.undefined; }); it('only processes an element once', function() { sinon.spy(React, 'cloneElement'); const component = genComponent(); const renderedElement = ( <div style={[ {background: 'white'}, {color: 'blue'} ]} /> ); let result = resolveStyles(component, renderedElement); result = resolveStyles(component, result); expect(result.props.style).to.deep.equal({ background: 'white', color: 'blue' }); expect(React.cloneElement).to.have.been.calledOnce; React.cloneElement.restore(); }); }); describe('style array', function() { it('merges an array of style objects', function() { const component = genComponent(); const renderedElement = ( <div style={[ {background: 'white'}, {color: 'blue'} ]} /> ); const result = resolveStyles(component, renderedElement); expect(result.props.style).to.deep.equal({ background: 'white', color: 'blue' }); }); it('skips falsy and non-object entries', function() { const component = genComponent(); const renderedElement = ( <div style={[ {background: 'white'}, false, null, ''.someUndefinedVar, '', [1, 2, 3], {color: 'blue'} ]} /> ); const result = resolveStyles(component, renderedElement); expect(result.props.style).to.deep.equal({ background: 'white', color: 'blue' }); }); it('overwrites earlier styles with later ones', function() { const component = genComponent(); const renderedElement = ( <div style={[ {background: 'white'}, {background: 'blue'} ]} /> ); const result = resolveStyles(component, renderedElement); expect(result.props.style).to.deep.equal({ background: 'blue' }); }); it('merges nested special styles', function() { const component = genComponent(); const renderedElement = ( <div style={[ {':hover': { background: 'white'}}, {':hover': {color: 'blue'}} ]} /> ); let result = resolveStyles(component, renderedElement); result.props.onMouseEnter(); result = resolveStyles(component, renderedElement); expect(result.props.style).to.deep.equal({ background: 'white', color: 'blue' }); }); }); const createPseduoStyleTests = function(pseudo, onHandlerName, offHandlerName) { it('strips special styles if not applied', function() { const component = genComponent(); const style = {background: 'blue'}; style[':' + pseudo] = {background: 'red'}; const renderedElement = <div style={style} />; const result = resolveStyles(component, renderedElement); expect(result.props.style).to.deep.equal({background: 'blue'}); }); it('adds appropriate handlers for ' + pseudo + ' styles', function() { const component = genComponent(); const style = {background: 'blue'}; style[':' + pseudo] = {background: 'red'}; const renderedElement = <div style={style} />; const result = resolveStyles(component, renderedElement); expect(typeof result.props[onHandlerName]).to.equal('function'); if (offHandlerName) { expect(typeof result.props[offHandlerName]).to.equal('function'); } }); it('adds ' + pseudo + ' styles ' + onHandlerName, function() { const component = genComponent(); const style = {background: 'blue'}; style[':' + pseudo] = {background: 'red'}; const renderedElement = <div style={style} />; let result = resolveStyles(component, renderedElement); expect(result.props.style.background).to.equal('blue'); result.props[onHandlerName](); expect(component.setState).to.have.been.called; // Must create a new renderedElement each time, same as React, since // resolveStyles mutates result = resolveStyles(component, renderedElement); expect(result.props.style.background).to.equal('red'); }); it('throws if multiple elements have the same key', function() { const component = genComponent(); const style = {background: 'blue'}; style[':' + pseudo] = {background: 'red'}; // Use ref instead of key here because React.Children.map will discard // the duplicate keyed element. const renderedElement = ( <div> <div ref="foo" style={style} /> <div ref="foo" style={style} /> </div> ); expect(() => { resolveStyles(component, renderedElement); }).to.throw(); }); it('throws if multiple elements have no key', function() { const component = genComponent(); const style = {background: 'blue'}; style[':' + pseudo] = {background: 'red'}; const renderedElement = ( <div> <div style={style} /> <div style={style} /> </div> ); expect(() => { resolveStyles(component, renderedElement); }).to.throw(); }); it('adds ' + pseudo + ' styles to correct element by key', function() { const component = genComponent(); const style = {background: 'blue'}; style[':' + pseudo] = {background: 'red'}; const renderedElement = ( <div> <div key="foo" /> <div key="bar" style={style} /> </div> ); let result = resolveStyles(component, renderedElement); let children = getChildrenArray(result.props.children); expect(children[0].props.style).to.be.undefined; expect(children[1].props.style.background).to.equal('blue'); children[1].props[onHandlerName](); result = resolveStyles(component, renderedElement); children = getChildrenArray(result.props.children); expect(children[0].props.style).to.be.undefined; expect(children[1].props.style.background).to.equal('red'); }); it('adds ' + pseudo + ' styles to correct element by ref', function() { const component = genComponent(); const style = {background: 'blue'}; style[':' + pseudo] = {background: 'red'}; const renderedElement = ( <div> <div ref="foo" /> <div ref="bar" style={style} /> </div> ); let result = resolveStyles(component, renderedElement); let children = getChildrenArray(result.props.children); expect(children[0].props.style).to.be.undefined; expect(children[1].props.style.background).to.equal('blue'); children[1].props[onHandlerName](); result = resolveStyles(component, renderedElement); children = getChildrenArray(result.props.children); expect(children[0].props.style).to.be.undefined; expect(children[1].props.style.background).to.equal('red'); }); if (offHandlerName) { it('removes ' + pseudo + ' styles ' + offHandlerName, function() { const component = genComponent(); const style = {background: 'blue'}; style[':' + pseudo] = {background: 'red'}; const renderedElement = <div style={style} />; let result = resolveStyles(component, renderedElement); result.props[onHandlerName](); result = resolveStyles(component, renderedElement); expect(result.props.style.background).to.equal('red'); result.props[offHandlerName](); expect(component.setState).to.have.been.called; result = resolveStyles(component, renderedElement); expect(result.props.style.background).to.equal('blue'); }); it('doesn\'t mutate state', function() { const component = genComponent(); const style = {background: 'blue'}; style[':' + pseudo] = {background: 'red'}; const renderedElement = <div style={style} />; let result = resolveStyles(component, renderedElement); // Capturing a reference to the existing state is enough, since Radium // MUST return a new copy for shouldComponentUpdate. let previousState = component.state._radiumStyleState; result.props[onHandlerName](); // If they are still equal here, that means we mutated the existing // state, which will break shouldComponentUpdate. expect(component.state._radiumStyleState).not.to.equal(previousState); result = resolveStyles(component, renderedElement); previousState = component.state._radiumStyleState; result.props[offHandlerName](); expect(component.state._radiumStyleState).not.to.equal(previousState); }); } }; describe(':hover', function() { createPseduoStyleTests('hover', 'onMouseEnter', 'onMouseLeave'); }); describe(':focus', function() { createPseduoStyleTests('focus', 'onFocus', 'onBlur'); }); describe(':active', function() { createPseduoStyleTests('active', 'onMouseDown'); it('subscribes to mouse up listener', function() { const component = genComponent(); const renderedElement = <div style={{':active': {background: 'red'}}} />; resolveStyles(component, renderedElement); expect(MouseUpListener.subscribe).to.have.been.called; }); it('adds active styles on mouse down', function() { const component = genComponent(); const style = { background: 'blue', ':active': {background: 'red'} }; const renderedElement = <div style={style} />; let result = resolveStyles(component, renderedElement); expect(result.props.style.background).to.equal('blue'); result.props.onMouseDown(); result = resolveStyles(component, renderedElement); expect(result.props.style.background).to.equal('red'); }); it('removes active styles on mouse up', function() { const component = genComponent(); const style = { background: 'blue', ':active': {background: 'red'} }; const renderedElement = <div style={style} />; let result = resolveStyles(component, renderedElement); result.props.onMouseDown(); result = resolveStyles(component, renderedElement); expect(result.props.style.background).to.equal('red'); // tigger global mouseup handler MouseUpListener.subscribe.firstCall.args[0](); result = resolveStyles(component, renderedElement); expect(result.props.style.background).to.equal('blue'); }); it('ignores mouse up if no active styles', function() { const component = genComponent(); const style = { background: 'blue', ':active': {background: 'red'} }; const renderedElement = <div style={style} />; let result = resolveStyles(component, renderedElement); result.props.onMouseDown(); // tigger global mouseup handler MouseUpListener.subscribe.firstCall.args[0](); MouseUpListener.subscribe.firstCall.args[0](); result = resolveStyles(component, renderedElement); expect(result.props.style.background).to.equal('blue'); }); it('calls existing onMouseDown handler', function() { const component = genComponent(); const style = { background: 'blue', ':active': {background: 'red'} }; const originalOnMouseDown = sinon.spy(); const renderedElement = ( <div onMouseDown={originalOnMouseDown} style={style} /> ); let result = resolveStyles(component, renderedElement); result.props.onMouseDown(); expect(originalOnMouseDown).to.have.been.called; result = resolveStyles(component, renderedElement); expect(result.props.style.background).to.equal('red'); }); }); describe('multiple states triggered at once', function() { describe('applies pseudo styles in the defined order', function() { const component = genComponent(); const stylePermutations = permutate([ {name: ':active', style: {background: 'red'}}, {name: ':focus', style: {background: 'yellow'}}, {name: ':hover', style: {background: 'blue'}} ]); const onHandlerPermutations = permutate([ 'onFocus', 'onMouseDown', 'onMouseEnter' ]); const createMultiPseudoTest = function(pseudoStyles, onHandlers) { const name = 'applies pseudo styles in the defined order: ' + pseudoStyles.map(pseudo => pseudo.name).join(', ') + ' when handlers called in order: ' + onHandlers.join(', '); it(name, function() { const style = {}; pseudoStyles.forEach(pseudo => { style[pseudo.name] = pseudo.style; }); const renderedElement = <div style={style} />; let result = resolveStyles(component, renderedElement); onHandlers.forEach(onHandler => { result.props[onHandler](); }); result = resolveStyles(component, renderedElement); expect(result.props.style.background).to.equal( pseudoStyles[pseudoStyles.length - 1].style.background ); }); }; stylePermutations.forEach(pseudoStyles => { onHandlerPermutations.forEach(onHandlers => { createMultiPseudoTest(pseudoStyles, onHandlers); }); }); }); }); describe('React.Children.only', function() { it('doesn\'t break React.Children.only', function() { const component = genComponent(); const renderedElement = <div><span /></div>; const result = resolveStyles(component, renderedElement); expect(React.Children.only(result.props.children)).to.be.ok; }); it('doesn\'t break when only child isn\'t ReactElement', function() { const component = genComponent(); const renderedElement = <div>Foo</div>; resolveStyles(component, renderedElement); }); }); describe('ReactComponentElement children', function() { it('doesn\'t resolve ReactComponentElement children', function() { const component = genComponent(); class CustomComponent extends React.Component {} const style = {':hover': {}}; const renderedElement = ( <div> <CustomComponent style={style}/> </div> ); const result = resolveStyles(component, renderedElement); const children = getChildrenArray(result.props.children); expect(children[0].props.style).to.deep.equal(style); }); it('resolves ReactDOMElement children of ReactComponentElements', function() { const component = genComponent(); class CustomComponent extends React.Component {} const style = [ {background: 'white'}, {color: 'blue'} ]; const renderedElement = ( <div style={style}> <CustomComponent style={style}> <div style={style} /> </CustomComponent> </div> ); const result = resolveStyles(component, renderedElement); expect(result.props.style).to.deep.equal({ background: 'white', color: 'blue' }); const children = getChildrenArray(result.props.children); expect(children[0].props.style).to.deep.equal(style); const componentChildren = getChildrenArray(children[0].props.children); expect(componentChildren[0].props.style).to.deep.equal({ background: 'white', color: 'blue' }); }); }); describe('disabled', function() { it('discards interaction styles if element is disabled', function() { const component = genComponent(); const style = {background: 'blue'}; style[':hover'] = {background: 'red'}; const renderedElement = ( <div> <div ref="foo" /> <div disabled ref="bar" style={style} /> </div> ); let result = resolveStyles(component, renderedElement); let children = getChildrenArray(result.props.children); expect(children[0].props.style).to.be.undefined; expect(children[1].props.style.background).to.equal('blue'); children[1].props.onMouseEnter(); result = resolveStyles(component, renderedElement); children = getChildrenArray(result.props.children); expect(children[0].props.style).to.be.undefined; expect(children[1].props.style.background).to.equal('blue'); }); it('styles according to :disabled style if element is disabled', function() { const component = genComponent(); const style = {background: 'blue'}; style[':hover'] = {background: 'red'}; style[':disabled'] = {background: 'yellow'}; const renderedElement = ( <div> <div ref="foo" /> <div disabled ref="bar" style={style} /> </div> ); let result = resolveStyles(component, renderedElement); let children = getChildrenArray(result.props.children); expect(children[0].props.style).to.be.undefined; expect(children[1].props.style.background).to.equal('yellow'); children[1].props.onMouseEnter(); result = resolveStyles(component, renderedElement); children = getChildrenArray(result.props.children); expect(children[0].props.style).to.be.undefined; expect(children[1].props.style.background).to.equal('yellow'); }); }); /* eslint-disable no-console */ describe('warnings', function() { beforeEach(() => { sinon.stub(console, 'warn'); }); afterEach(() => { console.warn.restore(); process.env.NODE_ENV = null; }); it('warns when mixing longhand and shorthand properties', function() { const component = genComponent(); const renderedElement = ( <div style={{ border: '1px solid black', borderWidth: '0 1px 1px 1px' }} /> ); resolveStyles(component, renderedElement); expect(console.warn).to.have.been.called; expect(console.warn.firstCall.args[0].indexOf('border')) .to.be.greaterThan(0); }); it('warns when mixing longhand and shorthand properties in nested styles', function() { const component = genComponent(); const renderedElement = ( <div style={{ ':hover': { border: '1px solid black', borderWidth: '0 1px 1px 1px' } }} /> ); resolveStyles(component, renderedElement); expect(console.warn).to.have.been.called; expect(console.warn.firstCall.args[0].indexOf('border')) .to.be.greaterThan(0); }); it('does not warn when mixing border and borderRadius', function() { const component = genComponent(); const renderedElement = ( <div style={{ border: '1px solid black', borderRadius: '5px' }} /> ); resolveStyles(component, renderedElement); expect(console.warn).to.not.have.been.called; }); it('does not throw when passed a falsy entry value', function() { const component = genComponent(); const renderedElement = <div style={{height: null }} />; expect(() => { resolveStyles(component, renderedElement); }).to.not.throw(); }); }); /* eslint-enable no-console */ });