diff --git a/.eslintrc b/.eslintrc index a1f25b83c6..e889f7dd81 100644 --- a/.eslintrc +++ b/.eslintrc @@ -12,6 +12,8 @@ ], "rules": { "comma-spacing": 2, + "comma-style": [2, "last"], + "one-var": [2, { "initialized": "never" }], "key-spacing": 0, "no-underscore-dangle": 0, "no-unused-vars": [2, { "vars": "all", "args": "none" }], diff --git a/CHANGELOG.md b/CHANGELOG.md index 526cb14ccc..725b336127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +v0.23.4 - Tue, 16 Jun 2015 00:37:04 GMT +--------------------------------------- + +- [0ce46b9](../../commit/0ce46b9) [changed] only autofocus modals when enforceFocus is true (the default) +- [c5855d2](../../commit/c5855d2) [changed] createChainedFunction to chain many functions, and to throw if non-functions are provided. +- [d18dadb](../../commit/d18dadb) [fixed] container content no longer shifts when overflowing +- [66f0f92](../../commit/66f0f92) [added] enforceFocus prop to Modal +- [3869ca2](../../commit/3869ca2) [fixed] Modal doesn't "jump" when container is overflowing + + + v0.23.3 - Fri, 12 Jun 2015 21:46:30 GMT --------------------------------------- diff --git a/docs/build.js b/docs/build.js index f167e47641..0bf9095c42 100644 --- a/docs/build.js +++ b/docs/build.js @@ -23,7 +23,9 @@ const readmeDest = path.join(docsBuilt, 'README.md'); */ function generateHTML(fileName) { return new Promise((resolve, reject) => { - Router.run(routes, '/' + fileName, Handler => { + const urlSlug = fileName === 'index.html' ? '/' : `/${fileName}`; + + Router.run(routes, urlSlug, Handler => { let html = React.renderToString(React.createElement(Handler)); html = '' + html; let write = fsp.writeFile(path.join(docsBuilt, fileName), html); diff --git a/docs/dev-run b/docs/dev-run index 68c047112e..e34cfb48ee 100755 --- a/docs/dev-run +++ b/docs/dev-run @@ -1,4 +1,3 @@ -#!/usr/bin/env babel-node /* eslint no-process-exit: 0 */ import 'colors'; import portfinder from 'portfinder'; diff --git a/docs/examples/ModalStatic.js b/docs/examples/ModalStatic.js index 545efbee73..f01a4d058e 100644 --- a/docs/examples/ModalStatic.js +++ b/docs/examples/ModalStatic.js @@ -1,6 +1,7 @@ const modalInstance = (
+ - diff --git a/package.json b/package.json index 86d5c5664e..348c77c3cd 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "test": "npm run lint && npm run build && karma start --single-run && _mocha --compilers js:babel-core/register test/server/*Spec.js", "lint": "eslint ./", "docs-build": "babel-node tools/build-cli.js --docs-only", - "docs": "docs/dev-run", + "docs": "babel-node docs/dev-run", "docs-prod": "npm run docs-build && NODE_ENV=production babel-node docs/server.js", "docs-prod-unoptimized": "npm run docs-build -- --dev && NODE_ENV=production babel-node docs/server.js" }, @@ -103,4 +103,4 @@ "babel-runtime": "^5.1.10", "classnames": "^2.0.0" } -} +} \ No newline at end of file diff --git a/src/CollapsibleNav.js b/src/CollapsibleNav.js index 1063764f60..427d0b30f3 100644 --- a/src/CollapsibleNav.js +++ b/src/CollapsibleNav.js @@ -29,9 +29,9 @@ const CollapsibleNav = React.createClass({ for (let key in nodes) { if (nodes.hasOwnProperty(key)) { - let n = React.findDOMNode(nodes[key]) - , h = n.offsetHeight - , computedStyles = domUtils.getComputedStyles(n); + let n = React.findDOMNode(nodes[key]); + let h = n.offsetHeight; + let computedStyles = domUtils.getComputedStyles(n); height += (h + parseInt(computedStyles.marginTop, 10) + diff --git a/src/FadeMixin.js b/src/FadeMixin.js index ddef53e59e..b5597d013d 100644 --- a/src/FadeMixin.js +++ b/src/FadeMixin.js @@ -57,8 +57,8 @@ export default { }, componentWillUnmount() { - let els = getElementsAndSelf(React.findDOMNode(this), ['fade']), - container = (this.props.container && React.findDOMNode(this.props.container)) || + let els = getElementsAndSelf(React.findDOMNode(this), ['fade']); + let container = (this.props.container && React.findDOMNode(this.props.container)) || domUtils.ownerDocument(this).body; if (els.length) { diff --git a/src/Modal.js b/src/Modal.js index a91cbe962f..bcd6c2db7e 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -11,7 +11,68 @@ import EventListener from './utils/EventListener'; // - Add `modal-body` div if only one child passed in that doesn't already have it // - Tests +/** + * Gets the correct clientHeight of the modal container + * when the body/window/document you need to use the docElement clientHeight + * @param {HTMLElement} container + * @param {ReactElement|HTMLElement} context + * @return {Number} + */ +function containerClientHeight(container, context) { + let doc = domUtils.ownerDocument(context); + + return (container === doc.body || container === doc.documentElement) + ? doc.documentElement.clientHeight + : container.clientHeight; +} + +function getContainer(context){ + return (context.props.container && React.findDOMNode(context.props.container)) || + domUtils.ownerDocument(context).body; +} + +/** + * Firefox doesn't have a focusin event so using capture is easiest way to get bubbling + * IE8 can't do addEventListener, but does have onfocusin, so we use that in ie8 + * @param {ReactElement|HTMLElement} context + * @param {Function} handler + */ +function onFocus(context, handler) { + let doc = domUtils.ownerDocument(context); + let useFocusin = !doc.addEventListener; + let remove; + + if (useFocusin) { + document.attachEvent('onfocusin', handler); + remove = () => document.detachEvent('onfocusin', handler); + } else { + document.addEventListener('focus', handler, true); + remove = () => document.removeEventListener('focus', handler, true); + } + return { remove }; +} + +let scrollbarSize; + +if (domUtils.canUseDom) { + let scrollDiv = document.createElement('div'); + + scrollDiv.style.position = 'absolute'; + scrollDiv.style.top = '-9999px'; + scrollDiv.style.width = '50px'; + scrollDiv.style.height = '50px'; + scrollDiv.style.overflow = 'scroll'; + + document.body.appendChild(scrollDiv); + + scrollbarSize = scrollDiv.offsetWidth - scrollDiv.clientWidth; + + document.body.removeChild(scrollDiv); + scrollDiv = null; +} + const Modal = React.createClass({ + mixins: [BootstrapMixin, FadeMixin], propTypes: { @@ -21,7 +82,8 @@ const Modal = React.createClass({ closeButton: React.PropTypes.bool, animation: React.PropTypes.bool, onRequestHide: React.PropTypes.func.isRequired, - dialogClassName: React.PropTypes.string + dialogClassName: React.PropTypes.string, + enforceFocus: React.PropTypes.bool }, getDefaultProps() { @@ -30,13 +92,20 @@ const Modal = React.createClass({ backdrop: true, keyboard: true, animation: true, - closeButton: true + closeButton: true, + enforceFocus: true }; }, + getInitialState(){ + return { }; + }, + render() { - let modalStyle = {display: 'block'}; + let state = this.state; + let modalStyle = { ...state.dialogStyles, display: 'block'}; let dialogClasses = this.getBsClassSet(); + delete dialogClasses.modal; dialogClasses['modal-dialog'] = true; @@ -66,7 +135,7 @@ const Modal = React.createClass({ ); return this.props.backdrop ? - this.renderBackdrop(modal) : modal; + this.renderBackdrop(modal, state.backdropStyles) : modal; }, renderBackdrop(modal) { @@ -91,8 +160,8 @@ const Modal = React.createClass({ let closeButton; if (this.props.closeButton) { closeButton = ( - - ); + + ); } return ( @@ -119,30 +188,63 @@ const Modal = React.createClass({ }, componentDidMount() { + const doc = domUtils.ownerDocument(this); + const win = domUtils.ownerWindow(this); + this._onDocumentKeyupListener = - EventListener.listen(domUtils.ownerDocument(this), 'keyup', this.handleDocumentKeyUp); + EventListener.listen(doc, 'keyup', this.handleDocumentKeyUp); + + this._onWindowResizeListener = + EventListener.listen(win, 'resize', this.handleWindowResize); + + if (this.props.enforceFocus) { + this._onFocusinListener = onFocus(this, this.enforceFocus); + } + + let container = getContainer(this); - let container = (this.props.container && React.findDOMNode(this.props.container)) || - domUtils.ownerDocument(this).body; container.className += container.className.length ? ' modal-open' : 'modal-open'; - this.focusModalContent(); + this._containerIsOverflowing = container.scrollHeight > containerClientHeight(container, this); + + this._originalPadding = container.style.paddingRight; + + if (this._containerIsOverflowing) { + container.style.paddingRight = parseInt(this._originalPadding || 0, 10) + scrollbarSize + 'px'; + } if (this.props.backdrop) { this.iosClickHack(); } + + this.setState(this._getStyles() //eslint-disable-line react/no-did-mount-set-state + , () => this.focusModalContent()); }, componentDidUpdate(prevProps) { if (this.props.backdrop && this.props.backdrop !== prevProps.backdrop) { this.iosClickHack(); + this.setState(this._getStyles()); //eslint-disable-line react/no-did-update-set-state + } + + if (this.props.container !== prevProps.container) { + let container = getContainer(this); + this._containerIsOverflowing = container.scrollHeight > containerClientHeight(container, this); } }, componentWillUnmount() { this._onDocumentKeyupListener.remove(); - let container = (this.props.container && React.findDOMNode(this.props.container)) || - domUtils.ownerDocument(this).body; + this._onWindowResizeListener.remove(); + + if (this._onFocusinListener) { + this._onFocusinListener.remove(); + } + + let container = getContainer(this); + + container.style.paddingRight = this._originalPadding; + container.className = container.className.replace(/ ?modal-open/, ''); this.restoreLastFocus(); @@ -162,10 +264,17 @@ const Modal = React.createClass({ } }, + handleWindowResize() { + this.setState(this._getStyles()); + }, + focusModalContent () { - this.lastFocus = domUtils.ownerDocument(this).activeElement; - let modalContent = React.findDOMNode(this.refs.modal); - modalContent.focus(); + if (this.props.enforceFocus) { + this.lastFocus = domUtils.activeElement(this); + + let modalContent = React.findDOMNode(this.refs.modal); + modalContent.focus(); + } }, restoreLastFocus () { @@ -173,6 +282,36 @@ const Modal = React.createClass({ this.lastFocus.focus(); this.lastFocus = null; } + }, + + enforceFocus() { + if ( !this.isMounted() ) { + return; + } + + let active = domUtils.activeElement(this); + let modal = React.findDOMNode(this.refs.modal); + + if (modal !== active && !domUtils.contains(modal, active)){ + modal.focus(); + } + }, + + _getStyles() { + if ( !domUtils.canUseDom ) { return {}; } + + let node = React.findDOMNode(this.refs.modal); + let scrollHt = node.scrollHeight; + let container = getContainer(this); + let containerIsOverflowing = this._containerIsOverflowing; + let modalIsOverflowing = scrollHt > containerClientHeight(container, this); + + return { + dialogStyles: { + paddingRight: containerIsOverflowing && !modalIsOverflowing ? scrollbarSize : void 0, + paddingLeft: !containerIsOverflowing && modalIsOverflowing ? scrollbarSize : void 0 + } + }; } }); diff --git a/src/Nav.js b/src/Nav.js index 46049bf41f..ed3b0d2ac9 100644 --- a/src/Nav.js +++ b/src/Nav.js @@ -36,9 +36,9 @@ const Nav = React.createClass({ }, getCollapsibleDimensionValue() { - let node = React.findDOMNode(this.refs.ul), - height = node.offsetHeight, - computedStyles = domUtils.getComputedStyles(node); + let node = React.findDOMNode(this.refs.ul); + let height = node.offsetHeight; + let computedStyles = domUtils.getComputedStyles(node); return height + parseInt(computedStyles.marginTop, 10) + parseInt(computedStyles.marginBottom, 10); }, diff --git a/src/utils/createChainedFunction.js b/src/utils/createChainedFunction.js index 61064ebfde..05a34cc3e2 100644 --- a/src/utils/createChainedFunction.js +++ b/src/utils/createChainedFunction.js @@ -4,22 +4,26 @@ * Will only create a new function if needed, * otherwise will pass back existing functions or null. * - * @param {function} one - * @param {function} two + * @param {function} functions to chain * @returns {function|null} */ -function createChainedFunction(one, two) { - let hasOne = typeof one === 'function'; - let hasTwo = typeof two === 'function'; +function createChainedFunction(...funcs) { + return funcs + .filter(f => f != null) + .reduce((acc, f) => { + if (typeof f !== 'function') { + throw new Error('Invalid Argument Type, must only provide functions, undefined, or null.'); + } - if (!hasOne && !hasTwo) { return null; } - if (!hasOne) { return two; } - if (!hasTwo) { return one; } + if (acc === null) { + return f; + } - return function chainedFunction() { - one.apply(this, arguments); - two.apply(this, arguments); - }; + return function chainedFunction(...args) { + acc.apply(this, args); + f.apply(this, args); + }; + }, null); } export default createChainedFunction; diff --git a/src/utils/domUtils.js b/src/utils/domUtils.js index cbac69af5d..31fa7c0340 100644 --- a/src/utils/domUtils.js +++ b/src/utils/domUtils.js @@ -1,5 +1,13 @@ import React from 'react'; + +let canUseDom = !!( + typeof window !== 'undefined' && + window.document && + window.document.createElement +); + + /** * Get elements owner document * @@ -11,6 +19,27 @@ function ownerDocument(componentOrElement) { return (elem && elem.ownerDocument) || document; } +function ownerWindow(componentOrElement) { + let doc = ownerDocument(componentOrElement); + return doc.defaultView + ? doc.defaultView + : doc.parentWindow; +} + +/** + * get the active element, safe in IE + * @return {HTMLElement} + */ +function getActiveElement(componentOrElement){ + let doc = ownerDocument(componentOrElement); + + try { + return doc.activeElement || doc.body; + } catch (e) { + return doc.body; + } +} + /** * Shortcut to compute element style * @@ -138,10 +167,13 @@ function contains(elem, inner){ } export default { + canUseDom, contains, + ownerWindow, ownerDocument, getComputedStyles, getOffset, getPosition, + activeElement: getActiveElement, offsetParent: offsetParentFunc }; diff --git a/test/ModalSpec.js b/test/ModalSpec.js index e81209d9e1..0e516d52dc 100644 --- a/test/ModalSpec.js +++ b/test/ModalSpec.js @@ -156,6 +156,36 @@ describe('Modal', function () { }, 0); }); + it('Should not focus on the Modal when enforceFocus is false', function (done) { + + document.activeElement.should.equal(focusableContainer); + + let Container = React.createClass({ + getInitialState() { + return {modalOpen: true}; + }, + render() { + if (this.state.modalOpen) { + return ( + {}} container={this}> + Message + + ); + } else { + return ; + } + } + }); + + React.render(, focusableContainer); + + setTimeout(function () { + // modal should be focused when opened + document.activeElement.should.equal(focusableContainer); + done(); + }, 0); + }); }); + }); diff --git a/test/PanelSpec.js b/test/PanelSpec.js index 8380a36431..6247c110b6 100644 --- a/test/PanelSpec.js +++ b/test/PanelSpec.js @@ -36,10 +36,10 @@ describe('Panel', function () { }); it('Should have custom component header', function () { - let header =

Heading

, - instance = ReactTestUtils.renderIntoDocument( - Panel content - ); + let header =

Heading

; + let instance = ReactTestUtils.renderIntoDocument( + Panel content + ); header = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithClass(instance, 'panel-heading')); assert.equal(header.firstChild.nodeName, 'H3'); assert.ok(header.firstChild.className.match(/\bpanel-title\b/)); @@ -47,10 +47,10 @@ describe('Panel', function () { }); it('Should have custom component header with anchor', function () { - let header =

Heading

, - instance = ReactTestUtils.renderIntoDocument( - Panel content - ); + let header =

Heading

; + let instance = ReactTestUtils.renderIntoDocument( + Panel content + ); header = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithClass(instance, 'panel-heading')); assert.equal(header.firstChild.nodeName, 'H3'); assert.ok(header.firstChild.className.match(/\bpanel-title\b/)); @@ -59,10 +59,10 @@ describe('Panel', function () { }); it('Should have custom component header with custom class', function () { - let header =

Heading

, - instance = ReactTestUtils.renderIntoDocument( - Panel content - ); + let header =

Heading

; + let instance = ReactTestUtils.renderIntoDocument( + Panel content + ); header = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithClass(instance, 'panel-heading')); assert.equal(header.firstChild.nodeName, 'H3'); assert.ok(header.firstChild.className.match(/\bpanel-title\b/)); diff --git a/test/utils/createChainedFunctionSpec.js b/test/utils/createChainedFunctionSpec.js new file mode 100644 index 0000000000..4b022d16e8 --- /dev/null +++ b/test/utils/createChainedFunctionSpec.js @@ -0,0 +1,71 @@ +/* eslint no-new-func: 0 */ +import createChainedFunction from '../../src/utils/createChainedFunction'; + +describe('createChainedFunction', function() { + it('returns null with no arguments', function() { + expect(createChainedFunction()).to.equal(null); + }); + + it('returns original function when single function is provided', function() { + const func1 = sinon.stub(); + createChainedFunction(func1).should.equal(func1); + }); + + it('wraps two functions with another that invokes both when called', function() { + const func1 = sinon.stub(); + const func2 = sinon.stub(); + const chained = createChainedFunction(func1, func2); + + chained + .should.not.equal(func1) + .and.should.not.equal(func2); + + func1.should.not.have.been.called; + func2.should.not.have.been.called; + + chained(); + + func1.should.have.been.calledOnce; + func2.should.have.been.calledOnce; + }); + + it('wraps multiple functions and invokes them in the order provided', function() { + const results = []; + const func1 = () => results.push(1); + const func2 = () => results.push(2); + const func3 = () => results.push(3); + const chained = createChainedFunction(func1, func2, func3); + chained(); + results.should.eql([1, 2, 3]); + }); + + it('forwards arguments to all chained functions', function() { + const in1 = 'herpa derpa'; + const in2 = { + herpa: 'derpa' + }; + + const func = (arg1, arg2) => { + arg1.should.equal(in1); + arg2.should.equal(in2); + }; + + const chained = createChainedFunction(func, func, func); + chained(in1, in2); + }); + + it('throws when func is not provided', function() { + expect(() => { + createChainedFunction({ herpa: 'derpa' }); + }).to.throw(/Invalid Argument Type/); + }); + + it('works with new Function call', function() { + const results = []; + const func1 = new Function('results', 'results.push(1);'); + const func2 = new Function('results', 'results.push(2);'); + const chained = createChainedFunction(func1, func2); + chained(results); + results.should.eql([1, 2]); + }); +});