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 + + + + + +``` + +## 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 ( +
+ { children } +
+ ); + } +} + +export default compose( [ + withFocusReturn, + withConstrainedTabbing, + clickOutside, + withGlobalEvents( { + keydown: 'handleKeyDown', + } ), +] )( ModalFrame ); diff --git a/components/modal/header.js b/components/modal/header.js new file mode 100644 index 00000000000000..4ce5fec67f7667 --- /dev/null +++ b/components/modal/header.js @@ -0,0 +1,41 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies. + */ +import IconButton from '../icon-button'; +import './style.scss'; + +const ModalHeader = ( { icon, title, onClose, closeLabel, headingId } ) => { + const label = closeLabel ? closeLabel : __( 'Close dialog' ); + + return ( +
+
+ { icon && + + { icon } + + } + { title && +

+ { title } +

+ } +
+ +
+ ); +}; + +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: //