diff --git a/components/higher-order/with-global-events/index.js b/components/higher-order/with-global-events/index.js
index a04b1d8020b351..e6859fb7f87cb2 100644
--- a/components/higher-order/with-global-events/index.js
+++ b/components/higher-order/with-global-events/index.js
@@ -8,7 +8,7 @@ import { forEach } from 'lodash';
*/
import {
Component,
- createRef,
+ forwardRef,
createHigherOrderComponent,
} from '@wordpress/element';
@@ -26,13 +26,12 @@ const listener = new Listener();
function withGlobalEvents( eventTypesToHandlers ) {
return createHigherOrderComponent( ( WrappedComponent ) => {
- return class extends Component {
+ class Wrapper extends Component {
constructor() {
super( ...arguments );
this.handleEvent = this.handleEvent.bind( this );
-
- this.ref = createRef();
+ this.handleRef = this.handleRef.bind( this );
}
componentDidMount() {
@@ -49,15 +48,24 @@ function withGlobalEvents( eventTypesToHandlers ) {
handleEvent( event ) {
const handler = eventTypesToHandlers[ event.type ];
- if ( typeof this.ref.current[ handler ] === 'function' ) {
- this.ref.current[ handler ]( event );
+ if ( typeof this.wrappedRef[ handler ] === 'function' ) {
+ this.wrappedRef[ handler ]( event );
}
}
+ handleRef( el ) {
+ this.wrappedRef = el;
+ this.props.forwardedRef( el );
+ }
+
render() {
- return ;
+ return ;
}
- };
+ }
+
+ return forwardRef( ( props, ref ) => {
+ return ;
+ } );
}, 'withGlobalEvents' );
}
diff --git a/components/higher-order/with-global-events/test/index.js b/components/higher-order/with-global-events/test/index.js
index 9054e5515d2524..31c89bffd77be9 100644
--- a/components/higher-order/with-global-events/test/index.js
+++ b/components/higher-order/with-global-events/test/index.js
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { mount } from 'enzyme';
+import TestRenderer from 'react-test-renderer';
/**
* External dependencies
@@ -57,25 +57,29 @@ describe( 'withGlobalEvents', () => {
}
} );
- function mountEnhancedComponent( props ) {
+ function mountEnhancedComponent( props = {} ) {
const EnhancedComponent = withGlobalEvents( {
resize: 'handleResize',
} )( OriginalComponent );
- wrapper = mount( Hello );
+ props.ref = () => {};
+
+ wrapper = TestRenderer.create( Hello );
}
it( 'renders with original component', () => {
mountEnhancedComponent();
- expect( wrapper.childAt( 0 ).childAt( 0 ).type() ).toBe( 'div' );
- expect( wrapper.childAt( 0 ).text() ).toBe( 'Hello' );
+ expect( wrapper.root.findByType( 'div' ).children[ 0 ] ).toBe( 'Hello' );
} );
it( 'binds events from passed object', () => {
mountEnhancedComponent();
- expect( Listener._instance.add ).toHaveBeenCalledWith( 'resize', wrapper.instance() );
+ // Get the HOC wrapper instance
+ const hocInstance = wrapper.root.findByType( OriginalComponent ).parent.instance;
+
+ expect( Listener._instance.add ).toHaveBeenCalledWith( 'resize', hocInstance );
} );
it( 'handles events', () => {
diff --git a/components/index.js b/components/index.js
index 1351d6fb7a4522..fe1888fbd9f379 100644
--- a/components/index.js
+++ b/components/index.js
@@ -27,6 +27,7 @@ export { default as KeyboardShortcuts } from './keyboard-shortcuts';
export { default as MenuGroup } from './menu-group';
export { default as MenuItem } from './menu-item';
export { default as MenuItemsChoice } from './menu-items-choice';
+export { default as Modal } from './modal';
export { default as ScrollLock } from './scroll-lock';
export { NavigableMenu, TabbableContainer } from './navigable-container';
export { default as Notice } from './notice';
diff --git a/components/modal/README.md b/components/modal/README.md
new file mode 100644
index 00000000000000..d8cbdd4c3de225
--- /dev/null
+++ b/components/modal/README.md
@@ -0,0 +1,162 @@
+Modal
+=======
+
+The modal is used to create an accessible modal over an application.
+
+**Note:** The API for this modal has been mimicked to resemble [`react-modal`](https://github.com/reactjs/react-modal).
+
+## Usage
+
+Render a screen overlay with a modal on top.
+```jsx
+
+
+
This modal is meant to be awesome!
+
+
+```
+
+## Implement close logic
+
+For the modal to properly work it's important you implement the close logic for the modal properly. The following example shows you how to properly implement a modal.
+
+```js
+const { Component, Fragment } = wp.element;
+const { Modal } = wp.components;
+
+class MyModalWrapper extends Component {
+ constructor() {
+ super( ...arguments );
+ this.state = {
+ isOpen: true,
+ }
+
+ this.openModal = this.openModal.bind( this );
+ this.closeModal = this.closeModal.bind( this );
+ }
+
+ openModal() {
+ if ( ! this.state.isOpen ) {
+ this.setState( { isOpen: true } );
+ }
+ }
+
+ closeModal() {
+ if ( this.state.isOpen ) {
+ this.setState( { isOpen: false } );
+ }
+ }
+
+ render() {
+ return (
+
+
+ { this.state.isOpen ?
+
+
+
+ : null }
+
+ );
+ }
+}
+```
+
+## Props
+
+The set of props accepted by the component will be specified below.
+Props not included in this set will be applied to the input elements.
+
+### title
+
+This property is used as the modal header's title. It is required for accessibility reasons.
+
+- Type: `String`
+- Required: Yes
+
+### onRequestClose
+
+This function is called to indicate that the modal should be closed.
+
+- Type: `function`
+- Required: Yes
+
+### contentLabel
+
+If this property is added, it will be added to the modal content `div` as `aria-label`.
+You are encouraged to use this if `aria.labelledby` is not provided.
+
+- Type: `String`
+- Required: No
+
+### aria.labelledby
+
+If this property is added, it will be added to the modal content `div` as `aria-labelledby`.
+You are encouraged to use this when the modal is visually labelled.
+
+- Type: `String`
+- Required: No
+- Default: `modal-heading`
+
+### aria.describedby
+
+If this property is added, it will be added to the modal content `div` as `aria-describedby`.
+
+- Type: `String`
+- Required: No
+
+### focusOnMount
+
+If this property is true, it will focus the first tabbable element rendered in the modal.
+
+- Type: `bool`
+- Required: No
+- Default: true
+
+### shouldCloseOnEsc
+
+If this property is added, it will determine whether the modal requests to close when the escape key is pressed.
+
+- Type: `bool`
+- Required: No
+- Default: true
+
+### shouldCloseOnClickOutside
+
+If this property is added, it will determine whether the modal requests to close when a mouse click occurs outside of the modal content.
+
+- Type: `bool`
+- Required: No
+- Default: true
+
+### className
+
+If this property is added, it will an additional class name to the modal content `div`.
+
+- Type: `String`
+- Required: No
+
+### role
+
+If this property is added, it will override the default role of the modal.
+
+- Type: `String`
+- Required: No
+- Default: `dialog`
+
+### overlayClassName
+
+If this property is added, it will an additional class name to the modal overlay `div`.
+
+- Type: `String`
+- Required: No
diff --git a/components/modal/aria-helper.js b/components/modal/aria-helper.js
new file mode 100644
index 00000000000000..8b714b147d55e4
--- /dev/null
+++ b/components/modal/aria-helper.js
@@ -0,0 +1,78 @@
+/**
+ * External dependencies
+ */
+import { forEach } from 'lodash';
+
+const LIVE_REGION_ARIA_ROLES = new Set( [
+ 'alert',
+ 'status',
+ 'log',
+ 'marquee',
+ 'timer',
+] );
+
+let hiddenElements = [],
+ isHidden = false;
+
+/**
+ * Hides all elements in the body element from screen-readers except
+ * the provided element and elements that should not be hidden from
+ * screen-readers.
+ *
+ * The reason we do this is because `aria-modal="true"` currently is bugged
+ * in Safari, and support is spotty in other browsers overall. In the future
+ * we should consider removing these helper functions in favor of
+ * `aria-modal="true"`.
+ *
+ * @param {Element} unhiddenElement The element that should not be hidden.
+ */
+export function hideApp( unhiddenElement ) {
+ if ( isHidden ) {
+ return;
+ }
+ const elements = document.body.children;
+ forEach( elements, ( element ) => {
+ if (
+ element === unhiddenElement
+ ) {
+ return;
+ }
+ if ( elementShouldBeHidden( element ) ) {
+ element.setAttribute( 'aria-hidden', 'true' );
+ hiddenElements.push( element );
+ }
+ } );
+ isHidden = true;
+}
+
+/**
+ * Determines if the passed element should not be hidden from screen readers.
+ *
+ * @param {HTMLElement} element The element that should be checked.
+ *
+ * @return {boolean} Whether the element should not be hidden from screen-readers.
+ */
+export function elementShouldBeHidden( element ) {
+ const role = element.getAttribute( 'role' );
+ return ! (
+ element.tagName === 'SCRIPT' ||
+ element.hasAttribute( 'aria-hidden' ) ||
+ element.hasAttribute( 'aria-live' ) ||
+ LIVE_REGION_ARIA_ROLES.has( role )
+ );
+}
+
+/**
+ * Makes all elements in the body that have been hidden by `hideApp`
+ * visible again to screen-readers.
+ */
+export function showApp() {
+ if ( ! isHidden ) {
+ return;
+ }
+ forEach( hiddenElements, ( element ) => {
+ element.removeAttribute( 'aria-hidden' );
+ } );
+ hiddenElements = [];
+ isHidden = false;
+}
diff --git a/components/modal/frame.js b/components/modal/frame.js
new file mode 100644
index 00000000000000..be9b44dde7c442
--- /dev/null
+++ b/components/modal/frame.js
@@ -0,0 +1,139 @@
+/**
+ * WordPress dependencies
+ */
+import { Component, compose, createRef } from '@wordpress/element';
+import { ESCAPE } from '@wordpress/keycodes';
+import { focus } from '@wordpress/dom';
+
+/**
+ * External dependencies
+ */
+import clickOutside from 'react-click-outside';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+import withFocusReturn from '../higher-order/with-focus-return';
+import withConstrainedTabbing from '../higher-order/with-constrained-tabbing';
+import withGlobalEvents from '../higher-order/with-global-events';
+
+class ModalFrame extends Component {
+ constructor() {
+ super( ...arguments );
+
+ this.containerRef = createRef();
+ this.handleKeyDown = this.handleKeyDown.bind( this );
+ this.handleClickOutside = this.handleClickOutside.bind( this );
+ this.focusFirstTabbable = this.focusFirstTabbable.bind( this );
+ }
+
+ /**
+ * Focuses the first tabbable element when props.focusOnMount is true.
+ */
+ componentDidMount() {
+ // Focus on mount
+ if ( this.props.focusOnMount ) {
+ this.focusFirstTabbable();
+ }
+ }
+
+ /**
+ * Focuses the first tabbable element.
+ */
+ focusFirstTabbable() {
+ const tabbables = focus.tabbable.find( this.containerRef.current );
+ if ( tabbables.length ) {
+ tabbables[ 0 ].focus();
+ }
+ }
+
+ /**
+ * Callback function called when clicked outside the modal.
+ *
+ * @param {Object} event Mouse click event.
+ */
+ handleClickOutside( event ) {
+ if ( this.props.shouldCloseOnClickOutside ) {
+ this.onRequestClose( event );
+ }
+ }
+
+ /**
+ * Callback function called when a key is pressed.
+ *
+ * @param {KeyboardEvent} event Key down event.
+ */
+ handleKeyDown( event ) {
+ if ( event.keyCode === ESCAPE ) {
+ this.handleEscapeKeyDown( event );
+ }
+ }
+
+ /**
+ * Handles a escape key down event.
+ *
+ * Calls onRequestClose and prevents default key press behaviour.
+ *
+ * @param {Object} event Key down event.
+ */
+ handleEscapeKeyDown( event ) {
+ if ( this.props.shouldCloseOnEsc ) {
+ event.preventDefault();
+ this.onRequestClose( event );
+ }
+ }
+
+ /**
+ * Calls the onRequestClose callback props when it is available.
+ *
+ * @param {Object} event Event object.
+ */
+ onRequestClose( event ) {
+ const { onRequestClose } = this.props;
+ if ( onRequestClose ) {
+ onRequestClose( event );
+ }
+ }
+
+ /**
+ * Renders the modal frame element.
+ *
+ * @return {WPElement} The modal frame element.
+ */
+ render() {
+ const {
+ contentLabel,
+ aria: {
+ describedby,
+ labelledby,
+ },
+ children,
+ className,
+ role,
+ style,
+ } = this.props;
+
+ return (
+
+ );
+};
+
+export default ModalHeader;
diff --git a/components/modal/index.js b/components/modal/index.js
new file mode 100644
index 00000000000000..63c719431efe77
--- /dev/null
+++ b/components/modal/index.js
@@ -0,0 +1,177 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+import { noop } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { Component, createPortal } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+import ModalFrame from './frame';
+import ModalHeader from './header';
+import * as ariaHelper from './aria-helper';
+import withInstanceId from '../higher-order/with-instance-id';
+
+// Used to count the number of open modals.
+let parentElement,
+ openModalCount = 0;
+
+class Modal extends Component {
+ constructor( props ) {
+ super( props );
+
+ this.prepareDOM();
+ }
+
+ /**
+ * Appends the modal's node to the DOM, so the portal can render the
+ * modal in it. Also calls the openFirstModal when this is the first modal to be
+ * opened.
+ */
+ componentDidMount() {
+ openModalCount++;
+
+ if ( openModalCount === 1 ) {
+ this.openFirstModal();
+ }
+ }
+
+ /**
+ * Removes the modal's node from the DOM. Also calls closeLastModal when this is
+ * the last modal to be closed.
+ */
+ componentWillUnmount() {
+ openModalCount--;
+
+ if ( openModalCount === 0 ) {
+ this.closeLastModal();
+ }
+
+ this.cleanDOM();
+ }
+
+ /**
+ * Prepares the DOM for the modals to be rendered.
+ *
+ * Every modal is mounted in a separate div appended to a parent div
+ * that is appended to the document body.
+ *
+ * The parent div will be created if it does not yet exist, and the
+ * separate div for this specific modal will be appended to that.
+ */
+ prepareDOM() {
+ if ( ! parentElement ) {
+ parentElement = document.createElement( 'div' );
+ document.body.appendChild( parentElement );
+ }
+ this.node = document.createElement( 'div' );
+ parentElement.appendChild( this.node );
+ }
+
+ /**
+ * Removes the specific mounting point for this modal from the DOM.
+ */
+ cleanDOM() {
+ parentElement.removeChild( this.node );
+ }
+
+ /**
+ * Prepares the DOM for this modal and any additional modal to be mounted.
+ *
+ * It appends an additional div to the body for the modals to be rendered in,
+ * it hides any other elements from screen-readers and adds an additional class
+ * to the body to prevent scrolling while the modal is open.
+ */
+ openFirstModal() {
+ ariaHelper.hideApp( parentElement );
+ document.body.classList.add( this.props.bodyOpenClassName );
+ }
+
+ /**
+ * Cleans up the DOM after the last modal is closed and makes the app available
+ * for screen-readers again.
+ */
+ closeLastModal() {
+ document.body.classList.remove( this.props.bodyOpenClassName );
+ ariaHelper.showApp();
+ }
+
+ /**
+ * Renders the modal.
+ *
+ * @return {WPElement} The modal element.
+ */
+ render() {
+ const {
+ overlayClassName,
+ className,
+ onRequestClose,
+ title,
+ icon,
+ closeButtonLabel,
+ children,
+ aria,
+ instanceId,
+ ...otherProps
+ } = this.props;
+
+ const headingId = (
+ aria.labelledby ||
+ 'components-modal-header-' + instanceId
+ );
+
+ return createPortal(
+
+
+
+
+ { children }
+
+
+
,
+ this.node
+ );
+ }
+}
+
+Modal.defaultProps = {
+ bodyOpenClassName: 'modal-open',
+ role: 'dialog',
+ title: null,
+ onRequestClose: noop,
+ focusOnMount: true,
+ shouldCloseOnEsc: true,
+ shouldCloseOnClickOutside: true,
+ /* accessibility */
+ aria: {
+ labelledby: null,
+ describedby: null,
+ },
+};
+
+export default withInstanceId( Modal );
diff --git a/components/modal/style.scss b/components/modal/style.scss
new file mode 100644
index 00000000000000..44f6187192b444
--- /dev/null
+++ b/components/modal/style.scss
@@ -0,0 +1,89 @@
+// The scrim behind the modal window.
+.components-modal__screen-overlay {
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ background-color: rgba( $white, .4 );
+ z-index: z-index( '.components-modal__screen-overlay' );
+}
+
+// The modal window element.
+.components-modal__frame {
+ // In small screens the content needs to be full width.
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ margin: 0;
+
+ // Show slightly bigger on small screens.
+ @include break-small() {
+ position: absolute;
+ right: auto;
+ bottom: auto;
+ max-width: calc( 100% - #{ $panel-padding } - #{ $panel-padding } );
+ margin-right: -50%;
+ transform: translate( -50%, 0 );
+ top: $panel-padding;
+ left: 50%;
+ height: 90%;
+ }
+
+ // Show pretty big on desktop breakpoints.
+ @include break-medium () {
+ max-width: calc( #{ $break-medium } - #{ $panel-padding } - #{ $panel-padding } );
+ transform: translate( -50%, -30% );
+ top: 30%;
+ left: 50%;
+ height: 70%;
+ }
+
+ border: 1px solid $light-gray-500;
+ background-color: $white;
+ box-shadow: $shadow-modal;
+ outline: none;
+}
+
+.components-modal__header {
+ box-sizing: border-box;
+ height: $header-height;
+ border-bottom: 1px solid $light-gray-500;
+ padding: $item-spacing $item-spacing $item-spacing $panel-padding;
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+ justify-content: space-between;
+}
+
+.components-modal__header-heading-container {
+ align-items: center;
+ flex-grow: 1;
+ display: flex;
+ flex-direction: row;
+ justify-content: left;
+}
+
+.components-modal__header-heading {
+ font-size: 1em;
+ font-weight: normal;
+}
+
+.components-modal__header-icon-container {
+ display: inline-block;
+
+ svg {
+ max-width: $icon-button-size;
+ max-height: $icon-button-size;
+ padding: 8px;
+ }
+}
+
+.components-modal__content {
+ // The height of the content is the height of it's parent, minus the header. after that, the offset was 3px.
+ height: calc( 100% - #{ $header-height } - #{ $admin-bar-height } );
+ overflow: auto;
+ padding: $panel-padding;
+}
diff --git a/components/modal/test/aria-helper.js b/components/modal/test/aria-helper.js
new file mode 100644
index 00000000000000..0ee9b0b947aa51
--- /dev/null
+++ b/components/modal/test/aria-helper.js
@@ -0,0 +1,69 @@
+/**
+ * Internal dependencies
+ */
+import { elementShouldBeHidden } from '../aria-helper';
+
+describe( 'aria-helper', () => {
+ describe( 'elementShouldBeHidden', () => {
+ it( 'should return true when a div element without attributes is passed', () => {
+ const element = document.createElement( 'div' );
+
+ expect( elementShouldBeHidden( element ) ).toBe( true );
+ } );
+
+ it( 'should return false when a script element without attributes is passed', () => {
+ const element = document.createElement( 'script' );
+
+ expect( elementShouldBeHidden( element ) ).toBe( false );
+ } );
+
+ it( 'should return false when an element has the aria-hidden attribute with value "true"', () => {
+ const element = document.createElement( 'div' );
+ element.setAttribute( 'aria-hidden', 'true' );
+
+ expect( elementShouldBeHidden( element ) ).toBe( false );
+ } );
+
+ it( 'should return false when an element has the aria-hidden attribute with value "false"', () => {
+ const element = document.createElement( 'div' );
+ element.setAttribute( 'aria-hidden', 'false' );
+
+ expect( elementShouldBeHidden( element ) ).toBe( false );
+ } );
+
+ it( 'should return false when an element has the role attribute with value "alert"', () => {
+ const element = document.createElement( 'div' );
+ element.setAttribute( 'role', 'alert' );
+
+ expect( elementShouldBeHidden( element ) ).toBe( false );
+ } );
+
+ it( 'should return false when an element has the role attribute with value "status"', () => {
+ const element = document.createElement( 'div' );
+ element.setAttribute( 'role', 'status' );
+
+ expect( elementShouldBeHidden( element ) ).toBe( false );
+ } );
+
+ it( 'should return false when an element has the role attribute with value "log"', () => {
+ const element = document.createElement( 'div' );
+ element.setAttribute( 'role', 'log' );
+
+ expect( elementShouldBeHidden( element ) ).toBe( false );
+ } );
+
+ it( 'should return false when an element has the role attribute with value "marquee"', () => {
+ const element = document.createElement( 'div' );
+ element.setAttribute( 'role', 'marquee' );
+
+ expect( elementShouldBeHidden( element ) ).toBe( false );
+ } );
+
+ it( 'should return false when an element has the role attribute with value "timer"', () => {
+ const element = document.createElement( 'div' );
+ element.setAttribute( 'role', 'timer' );
+
+ expect( elementShouldBeHidden( element ) ).toBe( false );
+ } );
+ } );
+} );
diff --git a/edit-post/assets/stylesheets/_variables.scss b/edit-post/assets/stylesheets/_variables.scss
index c8a14b37c19d0a..56394bab048cdf 100644
--- a/edit-post/assets/stylesheets/_variables.scss
+++ b/edit-post/assets/stylesheets/_variables.scss
@@ -29,6 +29,7 @@ $admin-sidebar-width-collapsed: 36px;
$shadow-popover: 0 3px 30px rgba( $dark-gray-900, .1 );
$shadow-toolbar: 0 2px 10px rgba( $dark-gray-900, .1 ), 0 0 2px rgba( $dark-gray-900, .1 );
$shadow-below-only: 0 5px 10px rgba( $dark-gray-900, .1 ), 0 2px 2px rgba( $dark-gray-900, .1 );
+$shadow-modal: 0 3px 30px rgba( $dark-gray-900, .2 );
// Editor Widths
$sidebar-width: 280px;
diff --git a/edit-post/assets/stylesheets/_z-index.scss b/edit-post/assets/stylesheets/_z-index.scss
index fe0c59226c6495..52f6327660f447 100644
--- a/edit-post/assets/stylesheets/_z-index.scss
+++ b/edit-post/assets/stylesheets/_z-index.scss
@@ -65,6 +65,9 @@ $z-layers: (
// #adminmenuwrap { z-index: 9990 }
'.components-notice-list': 9989,
+ // Show modal under the wp-admin menus and the popover
+ '.components-modal__screen-overlay': 100000,
+
// Show popovers above wp-admin menus and submenus and sidebar:
// #adminmenuwrap { z-index: 9990 }
'.components-popover': 1000000,
diff --git a/editor/components/provider/index.js b/editor/components/provider/index.js
index 788c29ea4df4f8..e7e480f68254ae 100644
--- a/editor/components/provider/index.js
+++ b/editor/components/provider/index.js
@@ -43,6 +43,7 @@ class EditorProvider extends Component {
redo,
createUndoLevel,
} = this.props;
+
const providers = [
// RichText provider:
//