From 7940ea0dd40cb069db594bd186a5d01ea328bafe Mon Sep 17 00:00:00 2001 From: Clay Diffrient Date: Wed, 8 Mar 2017 23:15:26 -0700 Subject: [PATCH] [changed] Replace appElement with getAppElement This makes getAppElement a required prop as well as makes it a function that will be called expecting a DOMElement. closes #287 This also takes some inspiration from #359 for handling arrays of objects. Upgrade Path: - If you had specified an appElement via `Modal.setAppElement`, then you need to convert that to a getAppElement prop on the modal, this should be a function that returns either a single element or an array of elements. - If you had nothing specified you will need to add the getAppElement element to prevent beakages. --- specs/Modal.spec.js | 226 +++++++++++++++++++++++------------- src/components/Modal.js | 17 ++- src/helpers/ariaAppHider.js | 37 ++---- 3 files changed, 163 insertions(+), 117 deletions(-) diff --git a/specs/Modal.spec.js b/specs/Modal.spec.js index c801fff3..c4a61dda 100644 --- a/specs/Modal.spec.js +++ b/specs/Modal.spec.js @@ -11,11 +11,18 @@ import sinon from 'sinon'; import expect from 'expect'; import ReactDOM from 'react-dom'; import Modal from '../src/components/Modal'; -import * as ariaAppHider from '../src/helpers/ariaAppHider'; import { renderModal, unmountModal, emptyDOM } from './helper'; const Simulate = TestUtils.Simulate; +function getDefaultProps () { + return { + getAppElement () {}, + contentLabel: 'Test Modal', + isOpen: true + }; +} + describe('Modal', () => { afterEach('check if test cleaned up rendered modals', emptyDOM); @@ -24,38 +31,28 @@ describe('Modal', () => { it('focuses the last focused element when tabbing in from browser chrome'); it('can be open initially', () => { - const component = renderModal({ isOpen: true }, 'hello'); + const component = renderModal(getDefaultProps(), 'hello'); expect(component.portal.content.innerHTML.trim()).toEqual('hello'); }); it('can be closed initially', () => { - const component = renderModal({}, 'hello'); + const props = { + ...getDefaultProps(), + isOpen: false + }; + const component = renderModal(props, 'hello'); expect(ReactDOM.findDOMNode(component.portal).innerHTML.trim()).toEqual(''); }); - it('accepts appElement as a prop', () => { - const el = document.createElement('div'); - const node = document.createElement('div'); - ReactDOM.render( - - , node); - expect(el.getAttribute('aria-hidden')).toEqual('true'); - ReactDOM.unmountComponentAtNode(node); - }); - it('renders into the body, not in context', () => { const node = document.createElement('div'); const App = () => (
- + node}> hello
); - Modal.setAppElement(node); ReactDOM.render(, node); const modalParent = document.body.querySelector('.ReactModalPortal').parentNode; expect(modalParent).toEqual(document.body); @@ -64,69 +61,62 @@ describe('Modal', () => { it('renders children', () => { const child = 'I am a child of Modal, and he has sent me here...'; - const component = renderModal({ isOpen: true }, child); + const component = renderModal(getDefaultProps(), child); expect(component.portal.content.innerHTML).toEqual(child); }); it('renders the modal content with a dialog aria role when provided ', () => { const child = 'I am a child of Modal, and he has sent me here...'; - const component = renderModal({ isOpen: true, role: 'dialog' }, child); + const component = renderModal({ ...getDefaultProps(), role: 'dialog' }, child); expect(component.portal.content.getAttribute('role')).toEqual('dialog'); }); it('renders the modal with a aria-label based on the contentLabel prop', () => { const child = 'I am a child of Modal, and he has sent me here...'; - const component = renderModal({ isOpen: true, contentLabel: 'Special Modal' }, child); - expect(component.portal.content.getAttribute('aria-label')).toEqual('Special Modal'); + const component = renderModal(getDefaultProps(), child); + expect(component.portal.content.getAttribute('aria-label')).toEqual('Test Modal'); }); it('has default props', () => { + const testProps = getDefaultProps(); const node = document.createElement('div'); - Modal.setAppElement(document.createElement('div')); - const component = ReactDOM.render(, node); + testProps.getAppElement = () => document.createElement('div'); + const component = ReactDOM.render(, node); const props = component.props; - expect(props.isOpen).toBe(false); expect(props.ariaHideApp).toBe(true); expect(props.closeTimeoutMS).toBe(0); expect(props.shouldCloseOnOverlayClick).toBe(true); ReactDOM.unmountComponentAtNode(node); - ariaAppHider.resetForTesting(); - Modal.setAppElement(document.body); // restore default }); it('removes the portal node', () => { - const component = renderModal({ isOpen: true }, 'hello'); + const component = renderModal(getDefaultProps(), 'hello'); expect(component.portal.content.innerHTML.trim()).toEqual('hello'); unmountModal(); expect(!document.querySelector('.ReactModalPortal')).toExist(); }); it('focuses the modal content', () => { - renderModal({ isOpen: true }, null, function checkModalContentFocus () { + renderModal(getDefaultProps(), null, function checkModalContentFocus () { expect(document.activeElement).toEqual(this.portal.content); }); }); it('give back focus to previous element or modal.', (done) => { - const modal = renderModal({ - isOpen: true, - onRequestClose () { - done(); - } - }, null, () => {}); - - renderModal({ - isOpen: true, - onRequestClose () { - Simulate.keyDown(modal.portal.content, { - // The keyCode is all that matters, so this works - key: 'FakeKeyToTestLater', - keyCode: 27, - which: 27 - }); - expect(document.activeElement).toEqual(modal.portal.content); - } - }, null, function checkPortalFocus () { + const testProps = getDefaultProps(); + testProps.onRequestClose = () => done(); + const modal = renderModal(testProps, null, () => {}); + const testProps2 = getDefaultProps(); + testProps2.onRequestClose = () => { + Simulate.keyDown(modal.portal.content, { + // The keyCode is all that matters, so this works + key: 'FakeKeyToTestLater', + keyCode: 27, + which: 27 + }); + expect(document.activeElement).toEqual(modal.portal.content); + }; + renderModal(testProps2, null, function checkPortalFocus () { expect(document.activeElement).toEqual(this.portal.content); Simulate.keyDown(this.portal.content, { // The keyCode is all that matters, so this works @@ -145,13 +135,13 @@ describe('Modal', () => { /> ); - renderModal({ isOpen: true }, input, () => { + renderModal(getDefaultProps(), input, () => { expect(document.activeElement).toEqual(document.querySelector('.focus_input')); }); }); it('handles case when child has no tabbable elements', () => { - const component = renderModal({ isOpen: true }, 'hello'); + const component = renderModal(getDefaultProps(), 'hello'); expect(() => { Simulate.keyDown(component.portal.content, { key: 'Tab', keyCode: 9, which: 9 }); }).toNotThrow(); @@ -159,7 +149,7 @@ describe('Modal', () => { it('keeps focus inside the modal when child has no tabbable elements', () => { let tabPrevented = false; - const modal = renderModal({ isOpen: true }, 'hello'); + const modal = renderModal(getDefaultProps(), 'hello'); expect(document.activeElement).toEqual(modal.portal.content); Simulate.keyDown(modal.portal.content, { key: 'Tab', @@ -171,47 +161,47 @@ describe('Modal', () => { }); it('supports portalClassName', () => { - const modal = renderModal({ isOpen: true, portalClassName: 'myPortalClass' }); + const modal = renderModal({ ...getDefaultProps(), portalClassName: 'myPortalClass' }); expect(modal.node.className).toEqual('myPortalClass'); }); it('supports custom className', () => { - const modal = renderModal({ isOpen: true, className: 'myClass' }); + const modal = renderModal({ ...getDefaultProps(), className: 'myClass' }); expect(modal.portal.content.className.indexOf('myClass')).toNotEqual(-1); }); it('supports overlayClassName', () => { - const modal = renderModal({ isOpen: true, overlayClassName: 'myOverlayClass' }); + const modal = renderModal({ ...getDefaultProps(), overlayClassName: 'myOverlayClass' }); expect(modal.portal.overlay.className.indexOf('myOverlayClass')).toNotEqual(-1); }); it('overrides the default styles when a custom classname is used', () => { - const modal = renderModal({ isOpen: true, className: 'myClass' }); + const modal = renderModal({ ...getDefaultProps(), className: 'myClass' }); expect(modal.portal.content.style.top).toEqual(''); }); it('overrides the default styles when a custom overlayClassName is used', () => { - const modal = renderModal({ isOpen: true, overlayClassName: 'myOverlayClass' }); + const modal = renderModal({ ...getDefaultProps(), overlayClassName: 'myOverlayClass' }); expect(modal.portal.overlay.style.backgroundColor).toEqual(''); }); it('supports adding style to the modal contents', () => { - const modal = renderModal({ isOpen: true, style: { content: { width: '20px' } } }); + const modal = renderModal({ ...getDefaultProps(), style: { content: { width: '20px' } } }); expect(modal.portal.content.style.width).toEqual('20px'); }); it('supports overriding style on the modal contents', () => { - const modal = renderModal({ isOpen: true, style: { content: { position: 'static' } } }); + const modal = renderModal({ ...getDefaultProps(), style: { content: { position: 'static' } } }); expect(modal.portal.content.style.position).toEqual('static'); }); it('supports adding style on the modal overlay', () => { - const modal = renderModal({ isOpen: true, style: { overlay: { width: '75px' } } }); + const modal = renderModal({ ...getDefaultProps(), style: { overlay: { width: '75px' } } }); expect(modal.portal.overlay.style.width).toEqual('75px'); }); it('supports overriding style on the modal overlay', () => { - const modal = renderModal({ isOpen: true, style: { overlay: { position: 'static' } } }); + const modal = renderModal({ ...getDefaultProps(), style: { overlay: { position: 'static' } } }); expect(modal.portal.overlay.style.position).toEqual('static'); }); @@ -220,45 +210,46 @@ describe('Modal', () => { // Just in case the default style is already relative, check that we can change it const newStyle = previousStyle === 'relative' ? 'static' : 'relative'; Modal.defaultStyles.content.position = newStyle; - const modal = renderModal({ isOpen: true }); + const modal = renderModal(getDefaultProps()); expect(modal.portal.content.style.position).toEqual(newStyle); Modal.defaultStyles.content.position = previousStyle; }); it('adds class to body when open', () => { - renderModal({ isOpen: false }); + const testProps = { ...getDefaultProps(), isOpen: false }; + renderModal(testProps); expect(document.body.className.indexOf('ReactModal__Body--open') !== -1).toEqual(false); unmountModal(); - renderModal({ isOpen: true }); + renderModal(getDefaultProps()); expect(document.body.className.indexOf('ReactModal__Body--open') !== -1).toEqual(true); unmountModal(); - renderModal({ isOpen: false }); + renderModal(testProps); expect(document.body.className.indexOf('ReactModal__Body--open') !== -1).toEqual(false); }); it('removes class from body when unmounted without closing', () => { - renderModal({ isOpen: true }); + renderModal(getDefaultProps()); expect(document.body.className.indexOf('ReactModal__Body--open') !== -1).toEqual(true); unmountModal(); expect(document.body.className.indexOf('ReactModal__Body--open') !== -1).toEqual(false); }); - it('removes aria-hidden from appElement when unmounted without closing', () => { + it('sets aria-hidden to false on appElement when unmounted without closing', () => { const el = document.createElement('div'); const node = document.createElement('div'); ReactDOM.render(React.createElement(Modal, { - isOpen: true, - appElement: el + ...getDefaultProps(), + getAppElement () { return el; } }), node); expect(el.getAttribute('aria-hidden')).toEqual('true'); ReactDOM.unmountComponentAtNode(node); - expect(el.getAttribute('aria-hidden')).toEqual(null); + expect(el.getAttribute('aria-hidden')).toEqual('false'); }); it('adds --after-open for animations', () => { - renderModal({ isOpen: true }); + renderModal(getDefaultProps()); const overlay = document.querySelector('.ReactModal__Overlay'); const content = document.querySelector('.ReactModal__Content'); expect(overlay.className.match(/ReactModal__Overlay--after-open/)).toExist(); @@ -268,7 +259,7 @@ describe('Modal', () => { it('should trigger the onAfterOpen callback', () => { const afterOpenCallback = sinon.spy(); renderModal({ - isOpen: true, + ...getDefaultProps(), onAfterOpen: afterOpenCallback }); expect(afterOpenCallback.called).toBeTruthy(); @@ -276,7 +267,7 @@ describe('Modal', () => { it('check the state of the modal after close with time out and reopen it', () => { const modal = renderModal({ - isOpen: true, + ...getDefaultProps(), closeTimeoutMS: 2000, onRequestClose () {} }); @@ -289,7 +280,7 @@ describe('Modal', () => { it('should close on Esc key event', () => { const requestCloseCallback = sinon.spy(); const modal = renderModal({ - isOpen: true, + ...getDefaultProps(), shouldCloseOnOverlayClick: true, onRequestClose: requestCloseCallback }); @@ -310,6 +301,75 @@ describe('Modal', () => { expect(event.key).toEqual('FakeKeyToTestLater'); }); + describe('Show/Hide appElement', () => { + let elementArray; + let node; + beforeEach(() => { + const el = document.createElement('div'); + const el2 = document.createElement('div'); + const el3 = document.createElement('div'); + elementArray = [el, el2, el3]; + node = document.createElement('div'); + }); + + it('hides an array of appElements', () => { + ReactDOM.render( + elementArray} + /> + , node); + const values = elementArray.map(ae => ae.getAttribute('aria-hidden')); + expect(values).toEqual(['true', 'true', 'true']); + ReactDOM.unmountComponentAtNode(node); + }); + + it('shows an array of appElements', () => { + ReactDOM.render( + elementArray} + /> + , node); + ReactDOM.unmountComponentAtNode(node); + const values = elementArray.map(ae => ae.getAttribute('aria-hidden')); + expect(values).toEqual(['false', 'false', 'false']); + }); + + it('hides a single appElement', () => { + ReactDOM.render( + elementArray[0]} + /> + , node); + expect(elementArray[0].getAttribute('aria-hidden')).toEqual('true'); + ReactDOM.unmountComponentAtNode(node); + }); + + it('shows a single appElement', () => { + ReactDOM.render( + elementArray[0]} + /> + , node); + ReactDOM.unmountComponentAtNode(node); + expect(elementArray[0].getAttribute('aria-hidden')).toEqual('false'); + }); + + it('throws an error if appElement is not provided', () => { + function renderError () { + ReactDOM.render( + + , node); + } + expect(renderError).toThrow('react-modal: Setting an getAppElement function is required'); + }); + }); + describe('should close on overlay click', () => { afterEach('Unmount modal', emptyDOM); @@ -317,12 +377,12 @@ describe('Modal', () => { afterEach('Unmount modal', emptyDOM); it('verify default prop of shouldCloseOnOverlayClick', () => { - const modal = renderModal({ isOpen: true }); + const modal = renderModal(getDefaultProps()); expect(modal.props.shouldCloseOnOverlayClick).toEqual(true); }); it('verify prop of shouldCloseOnOverlayClick', () => { - const modal = renderModal({ isOpen: true, shouldCloseOnOverlayClick: false }); + const modal = renderModal({ ...getDefaultProps(), shouldCloseOnOverlayClick: false }); expect(modal.props.shouldCloseOnOverlayClick).toEqual(false); }); }); @@ -333,7 +393,7 @@ describe('Modal', () => { it('verify overlay click when shouldCloseOnOverlayClick sets to false', () => { const requestCloseCallback = sinon.spy(); const modal = renderModal({ - isOpen: true, + ...getDefaultProps(), shouldCloseOnOverlayClick: false }); expect(modal.props.isOpen).toEqual(true); @@ -346,7 +406,7 @@ describe('Modal', () => { it('verify overlay click when shouldCloseOnOverlayClick sets to true', () => { const requestCloseCallback = sinon.spy(); const modal = renderModal({ - isOpen: true, + ...getDefaultProps(), shouldCloseOnOverlayClick: true, onRequestClose () { requestCloseCallback(); @@ -362,7 +422,7 @@ describe('Modal', () => { it('verify overlay mouse down and content mouse up when shouldCloseOnOverlayClick sets to true', () => { const requestCloseCallback = sinon.spy(); const modal = renderModal({ - isOpen: true, + ...getDefaultProps(), shouldCloseOnOverlayClick: true, onRequestClose: requestCloseCallback }); @@ -379,7 +439,7 @@ describe('Modal', () => { it('verify content mouse down and overlay mouse up when shouldCloseOnOverlayClick sets to true', () => { const requestCloseCallback = sinon.spy(); const modal = renderModal({ - isOpen: true, + ...getDefaultProps(), shouldCloseOnOverlayClick: true, onRequestClose () { requestCloseCallback(); @@ -398,7 +458,7 @@ describe('Modal', () => { it('should not stop event propagation', () => { let hasPropagated = false; const modal = renderModal({ - isOpen: true, + ...getDefaultProps(), shouldCloseOnOverlayClick: true }); const overlay = TestUtils.scryRenderedDOMComponentsWithClass(modal.portal, 'ReactModal__Overlay'); @@ -419,7 +479,7 @@ describe('Modal', () => { it('verify event passing on overlay click', () => { const requestCloseCallback = sinon.spy(); const modal = renderModal({ - isOpen: true, + ...getDefaultProps(), shouldCloseOnOverlayClick: true, onRequestClose: requestCloseCallback }); @@ -443,7 +503,7 @@ describe('Modal', () => { it('adds --before-close for animations', () => { const closeTimeoutMS = 50; const modal = renderModal({ - isOpen: true, + ...getDefaultProps(), closeTimeoutMS }); @@ -463,7 +523,7 @@ describe('Modal', () => { const closeTimeoutMS = 50; renderModal({ - isOpen: true, + ...getDefaultProps(), closeTimeoutMS }); diff --git a/src/components/Modal.js b/src/components/Modal.js index 2089f81e..59abe32d 100644 --- a/src/components/Modal.js +++ b/src/components/Modal.js @@ -1,13 +1,11 @@ import React, { Component } from 'react'; import ReactDOM from 'react-dom'; -import ExecutionEnvironment from 'exenv'; import elementClass from 'element-class'; import ModalPortal from './ModalPortal'; import * as ariaAppHider from '../helpers/ariaAppHider'; const renderSubtreeIntoContainer = ReactDOM.unstable_renderSubtreeIntoContainer; -const SafeHTMLElement = ExecutionEnvironment.canUseDOM ? window.HTMLElement : {}; function getParentElement (parentSelector) { return parentSelector(); @@ -23,7 +21,12 @@ export default class Modal extends Component { overlay: React.PropTypes.object }), portalClassName: React.PropTypes.string, - appElement: React.PropTypes.instanceOf(SafeHTMLElement), + /** + * A function that returns the appElement that will be aria-hidden + * when the modal is open. The function should return a DOMElement or + * an array of DOMElements. + */ + getAppElement: React.PropTypes.func.isRequired, onAfterOpen: React.PropTypes.func, onRequestClose: React.PropTypes.func, closeTimeoutMS: React.PropTypes.number, @@ -69,10 +72,6 @@ export default class Modal extends Component { } }; - static setAppElement (element) { - ariaAppHider.setElement(element); - } - static injectCSS () { return process.env.NODE_ENV !== 'production' && console.warn('React-Modal: injectCSS has been deprecated ' + @@ -102,7 +101,7 @@ export default class Modal extends Component { componentWillUnmount () { if (this.props.ariaHideApp) { - ariaAppHider.show(this.props.appElement); + ariaAppHider.show(this.props.getAppElement()); } const state = this.portal.state; @@ -137,7 +136,7 @@ export default class Modal extends Component { } if (props.ariaHideApp) { - ariaAppHider.toggle(props.isOpen, props.appElement); + ariaAppHider.toggle(this.props.getAppElement(), props.isOpen); } this.portal = renderSubtreeIntoContainer(this, diff --git a/src/helpers/ariaAppHider.js b/src/helpers/ariaAppHider.js index f5ee72e9..aa4b83fe 100644 --- a/src/helpers/ariaAppHider.js +++ b/src/helpers/ariaAppHider.js @@ -1,37 +1,24 @@ -let globalElement = typeof document !== 'undefined' ? document.body : null; - function validateElement (appElement) { - if (!appElement && !globalElement) { - throw new Error('react-modal: You must set an element with `Modal.setAppElement(el)` to make this accessible'); + if (!appElement) { + throw new Error('react-modal: Setting an getAppElement function is required'); } } -export function setElement (element) { - let newElement = element; - if (typeof newElement === 'string') { - const el = document.querySelectorAll(element); - newElement = 'length' in el ? el[0] : el; +export function toggle (appElement, value) { + validateElement(appElement); + if (Array.isArray(appElement)) { + appElement.forEach((ae) => { + ae.setAttribute('aria-hidden', value); + }); + } else { + appElement.setAttribute('aria-hidden', value); } - globalElement = newElement || globalElement; - return globalElement; } export function hide (appElement) { - validateElement(appElement); - (appElement || globalElement).setAttribute('aria-hidden', 'true'); + toggle(appElement, true); } export function show (appElement) { - validateElement(appElement); - (appElement || globalElement).removeAttribute('aria-hidden'); -} - -export function toggle (shouldHide, appElement) { - if (shouldHide) { - hide(appElement); - } else { show(appElement); } -} - -export function resetForTesting () { - globalElement = document.body; + toggle(appElement, false); }