diff --git a/package.json b/package.json index 96beba7..bc04907 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "test": "npm run test:cover", "test:watch": "npm run test:nocover -- --watch --watch-extensions js", "test:nocover": "mocha --compilers js:babel-core/register --require ./test/test_helper.js './test/**/*.js'", + "test:debug": "mocha --compilers js:babel-core/register --debug-brk --require ./test/test_helper.js './test/**/*.js'", "test:cover": "istanbul cover --root src/ --include-all-sources --report lcov --report json --report text --report html _mocha -- -r babel-register -r test/test_helper.js 'test/**/*.test.js'", "lint": "eslint src/ test/ || true", "lint:failfast": "eslint src/ test/", diff --git a/src/higher_order_components/focusable.js b/src/higher_order_components/focusable.js index 3342de1..cfa395a 100644 --- a/src/higher_order_components/focusable.js +++ b/src/higher_order_components/focusable.js @@ -36,10 +36,14 @@ module.exports = function Focusable(ComponentClass) { window.removeEventListener('focus', this._onFocus, true) } + // Handles most cases of the user clicking in another field, or anywhere + // outside the focusable element. _onDocumentClick(e) { this.setState({ focused: this._containsDOMElement(e.target) }) } + // Also cover the case where the user tabs out of a focusable element with + // keyboard (since this wouldn't create a click event). _onFocus() { this.setState({ focused: this._containsDOMElement(document.activeElement) }) } diff --git a/test/higher_order_components/focusable.test.js b/test/higher_order_components/focusable.test.js new file mode 100644 index 0000000..ec6a7c8 --- /dev/null +++ b/test/higher_order_components/focusable.test.js @@ -0,0 +1,103 @@ +/* global describe, it, beforeEach */ + +import { jsdom } from 'jsdom' +import { usingJsdom } from '../test_helper' + +import React from 'react' +import ReactDOM from 'react-dom' +import { expect } from 'chai' +// import Form from '../../src/components/form' +import { mount } from 'enzyme' +import td from 'testdouble' +import focusable from '../../src/higher_order_components/focusable.js' + +class Layout extends React.Component { // eslint-disable-line react/prefer-stateless-function + render() { + return (
+ + + +
) + } +} + +@focusable +class ExampleFocusable extends React.Component { // eslint-disable-line react/no-multi-comp + static propTypes = { + focused: React.PropTypes.boolean, + } + render() { + return ( +
+ + {this.props.focused &&

Focused!

} +
+ ) + } +} + +const sendFocusEvent = (el) => { + // .focus() only sets document.activeElement. + // We also need to dispatch the event. + el.focus() + el.dispatchEvent(new Event('focus')) +} + +describe('@focusable decorator', () => { + // Note: Because this test uses `usingJsdom`, it executes as a single + // test with multiple assertions. `usingJsdom` cannot be used in a + // describe block, since it mutates globals, and tests execute at a + // different time. + + it('toggles props.focused when child component is clicked/focused/blurred', () => { + const dom = jsdom('
') + + usingJsdom(dom, () => { + ReactDOM.render(, dom.getElementById('app')) + + const app = dom.getElementById('app') + const input = dom.querySelectorAll('.theInput')[0] + const span = dom.querySelectorAll('.before')[0] + + expect(app.textContent).to.equal('', + 'initially renders child component with props.focused=false') + + // click inside the Focusable + input.click() + expect(app.textContent).to.equal('Focused!', + 'sets props.focused=true on child component when it is clicked') + + // click outside the Focusable + span.click() + expect(app.textContent).to.equal('', + 'sets props.focused=false on child component when another element is clicked') + + // focus inside the Focusable + sendFocusEvent(input) + expect(app.textContent).to.equal('Focused!', + 'sets props.focused=true on child component when it is focused') + + + // focus outside the Focusable + sendFocusEvent(span) + expect(app.textContent).to.equal('', + 'sets props.focused=false on child component when another element is focused') + }) + }) + + + it('removes event listeners when component unmounts', () => { + // There is no programatic way to get a list of event listeners. + // Only way to test this is to monkey-patch window.removeEventListener. + const oldRemoveEventListener = window.removeEventListener + window.removeEventListener = td.function() + + // Mount and immediately unmount => calls componentWillUnmount + mount().unmount() + + td.verify(window.removeEventListener('click'), { ignoreExtraArgs: true }) + td.verify(window.removeEventListener('focus'), { ignoreExtraArgs: true }) + + window.removeEventListener = oldRemoveEventListener + }) +}) diff --git a/test/test_helper.js b/test/test_helper.js index 1c9ef44..885b0c3 100644 --- a/test/test_helper.js +++ b/test/test_helper.js @@ -2,16 +2,39 @@ import { jsdom } from 'jsdom' import chai from 'chai' import dirtyChai from 'dirty-chai' -const doc = jsdom('') -const win = doc.defaultView +chai.use(dirtyChai) -global.document = doc -global.window = win +const globalify = (doc, win) => { + global.document = doc + global.window = win -Object.keys(window).forEach((key) => { - if (!(key in global)) { - global[key] = window[key] - } -}) + Object.keys(window).forEach((key) => { + if (!(key in global)) { + global[key] = window[key] + } + }) +} -chai.use(dirtyChai) +{ + const doc = jsdom('') + const win = doc.defaultView + globalify(doc, win) +} + +// Use `usingJsdom` to wrap any tests that need to use their own jsdom. +// It will swap in the supplied jsdom, execute the callback, and then +// restore the test_helper jsdom defined above. + +// Note: Only use this in a single test (`it` block)! Putting it in +// a `describe` block will cause unwanted global pollution. +export const usingJsdom = (doc, callback) => { + const oldWin = global.window + const oldDoc = global.document + + const win = doc.defaultView + globalify(doc, win) + + callback() + + globalify(oldDoc, oldWin) +}