Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Modal component #6261

Merged
merged 77 commits into from
Jul 4, 2018
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
bf9f287
Initial implementation modal
Apr 18, 2018
aba9636
removed style prop assignment causing error
Apr 19, 2018
b0700fb
Set default mount node to #wpwrap
Apr 23, 2018
66367dd
Implemented default styling
Apr 25, 2018
aca49d0
Improved styling
Apr 25, 2018
3a5d75c
Applied code review feedback to with-focus-contain HOC
Apr 25, 2018
371e66b
Added eslint ignore for jsx-a11y/no-static-element-interactions
Apr 25, 2018
bd48b5a
Implemented withGlobalEvents HOC
Apr 25, 2018
809bfdd
withGlobalEvents HOC now forwards refs
Apr 30, 2018
47219be
Replace lodash defer with withSafeTimeout
Apr 30, 2018
9b93633
Removed unnecessary return statements
Apr 30, 2018
de29a46
Created separate styling rules
May 1, 2018
4a1b2c4
Added documentation for forwardRef function
May 1, 2018
1092fbd
Made mount location for modal configurable
May 1, 2018
44d91d3
Renamed elementId to appElementId for clarity
May 1, 2018
4aef9b6
Added noop for when no reg is provided in forwardRef
May 1, 2018
5c0568c
Fixed error in EditorProvider
May 1, 2018
887193d
Fix eslint errors
May 1, 2018
b0c4d82
Fixed incorrectly bound function
May 1, 2018
f25b989
Modal now by default mounts to the body and hides all other elements
May 2, 2018
c360742
hideApp no longer unhides elements that already had a aria-hidden=tru…
May 2, 2018
984ef8e
Improved a11y and updated documentation
May 2, 2018
6563c63
Updated documentation
May 2, 2018
59255ff
Removed forwardRef from element|
May 2, 2018
c6ee481
Changed default close label to Close dialog
May 2, 2018
17b7957
Add modal-open className to body when modal is opened
May 2, 2018
c4b433b
Added documentation to modal/index.js
May 2, 2018
cca2a0e
Removed aria-modal=true and explained why in aria-helper.js
May 2, 2018
21a722c
Documented modal/frame.js
May 2, 2018
9313ecb
Merge branch 'master' into add/modal
xyfi May 8, 2018
f487f22
Merge branch 'master' into add/modal
atimmer May 23, 2018
a66f5b8
Merge branch 'master' into add/modal
abotteram May 29, 2018
359774d
Merge branch 'add/modal' of https://github.com/WordPress/gutenberg in…
abotteram May 29, 2018
e7a8f4f
Addressed eslint issues
abotteram May 29, 2018
94a3216
Polish the visuals a bit.
Jun 6, 2018
064adbc
Merge branch 'master' into add/modal
abotteram Jun 7, 2018
832b1ac
Merge branch 'add/modal' of https://github.com/WordPress/gutenberg in…
abotteram Jun 7, 2018
6ac30dd
Disabled jsx-a11y/no-static-element-interactions in render function o…
abotteram Jun 7, 2018
349b068
Merge branch 'master' into add/modal
abotteram Jun 13, 2018
d1d2ba6
Merge branch 'master' into add/modal
abotteram Jun 18, 2018
eda32a6
Addressed CR concerns
abotteram Jun 18, 2018
dde71f2
Removed unused variable
abotteram Jun 18, 2018
ca8512a
Replaced focus.tabbables.find from @wordpress/utils with @wordpress/dom
abotteram Jun 18, 2018
afdaa2c
Merge branch 'master' into add/modal
abotteram Jun 20, 2018
b6ef31f
Fixed failing tests after updating react-test-renderer to version 16.…
abotteram Jun 20, 2018
b74d1e6
CSS Tweaks
abotteram Jun 21, 2018
dbac197
Fixed error when clicking outside of the modal
abotteram Jun 26, 2018
61ac955
Move focus to first element with tabindex=-1 on mount
abotteram Jun 26, 2018
d94e4ef
Make sure the dic the modal is renderd in is apprended to the documen…
abotteram Jun 27, 2018
6eb3886
Addressed minor codestyle issues in frame.js
abotteram Jun 27, 2018
ee6f520
Fixed bug when opening modal the second time
abotteram Jun 27, 2018
ca59960
Removed unused import
abotteram Jun 27, 2018
e2a50a1
replaced react-click-outside with internal withFocusOutside HOC
abotteram Jun 27, 2018
010f223
Merge branch 'master' into add/modal
abotteram Jul 2, 2018
9968113
Replaced withFocusContain with withConstrainedTabbing
abotteram Jul 2, 2018
5dc9b58
Replaced withFocusOutside with react-click-outside again
abotteram Jul 2, 2018
7f82944
Replaced @wordpress/utils keycodes with @wordpress/keycodes in frame.js
abotteram Jul 3, 2018
146f562
don't pass props.aria.labelledby to frame div when props.contentLabel…
abotteram Jul 3, 2018
30ab946
Added logic and tests for aria-helper to not hide (implicitely) live …
abotteram Jul 3, 2018
bd1050b
Removed isOpen props from the modal
abotteram Jul 3, 2018
d5f50d1
Removed the ability to add inline styles to the modal
abotteram Jul 3, 2018
2a0c916
Removed useless css
abotteram Jul 3, 2018
9400adc
Removed redundant z-index
abotteram Jul 3, 2018
a956732
Add full page overlay
abotteram Jul 3, 2018
0679405
Don't render h1 tag when no title is provided
abotteram Jul 3, 2018
4e3bb1a
Made modal screen-verlay full screen
abotteram Jul 3, 2018
12dd5fe
generate unique id for modal labelledby attribute
abotteram Jul 3, 2018
f607330
Replaced function scoped array liveRegionAriaRoles with file scoped c…
abotteram Jul 3, 2018
40cc3f9
Removed check whtehr forwardedRef is defined
abotteram Jul 3, 2018
0590e39
Minor JSDoc improvement
abotteram Jul 3, 2018
6f57d08
Removed styles from defaultProps
abotteram Jul 3, 2018
c547404
Documentation improvements
abotteram Jul 3, 2018
4eba6c7
don't add labelledBy attribute to modal frame when no title is present
abotteram Jul 3, 2018
9ee5b83
Don't add unique id to headingId when aria.labelledby prop is provide…
abotteram Jul 3, 2018
1a0bf75
Components: Reorder component lifecycle as first class members
aduth Jul 4, 2018
8778706
Components: Use withInstanceId to generate modal heading id
aduth Jul 4, 2018
6a43d9f
Components: Fix modal withInstanceId import reference
aduth Jul 4, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions components/higher-order/with-focus-contain/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* WordPress dependencies
*/
import { Component, createRef } from '@wordpress/element';
import { focus } from '@wordpress/utils';

const withFocusContain = ( WrappedComponent ) => {
return class extends Component {
constructor() {
super( ...arguments );

this.focusContainRef = createRef();
this.handleTabBehaviour = this.handleTabBehaviour.bind( this );
}

handleTabBehaviour( event ) {
if ( event.keyCode === 9 ) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a utility inside @wordpress/utils named keycodes which will allow you to use a readable version of the keycode instead of using the number.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Early return lets you avoid indenting the rest of the function, and I find to be generally more readable:

if ( event.keyCode !== 9 ) {
	return;
}

const tabbables = focus.tabbable.find( this.focusContainRef.current );
if ( ! tabbables.length ) {
return;
}
const firstTabbable = tabbables[ 0 ];
const lastTabbable = tabbables[ tabbables.length - 1 ];

if ( event.shiftKey && event.target === firstTabbable ) {
event.preventDefault();
return lastTabbable.focus();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What/why are we return-ing here?

} else if ( ! event.shiftKey && event.target === lastTabbable ) {
event.preventDefault();
return firstTabbable.focus();
}
}
}

componentDidMount() {
this.focusContainRef.current.addEventListener( 'keydown', this.handleTabBehaviour );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to bind this on the node directly instead of a prop onKeyDown on the rendered div ?

Should we include a code comment informing future maintainers?

}

componentWillUnmount() {
this.focusContainRef.current.addEventListener( 'keydown', this.handleTabBehaviour );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removeEventListener ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See next comment.

}

render() {
return (
<div ref={ this.focusContainRef }>
<WrappedComponent { ...this.props } />
</div>
);
}
};
};

export default withFocusContain;
1 change: 1 addition & 0 deletions components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,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';
Expand Down
117 changes: 117 additions & 0 deletions components/modal/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
RangeControl
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy-pasta. 🍝

=======

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`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we link to react-modal documentation, if the intent is to allow people to apply common knowledge between the two projects?

Should we clarify whether the intent is for it to continue to strictly adhere to a compatible API into the future?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've already diverged from the react-modal api quite a bit, maybe just leaving out the reference to react-modal all together would be a better idea.


## Usage

Render a screen overlay with a modal on top.
```js
// When the app element is set it puts an aria-hidden="true" to the provided node.
Modal.setAppElement( document.getElementById( 'wpwrap' ).parentNode )
```
```jsx
<Modal
aria={ {
labelledby: 'modal-title',
describedby: 'modal-description',
} }
parentSelector={ () => {
return document.getElementById( 'wpwrap' );
} )
>
<ModalContent>
<h2 id="modal-title">My awesome modal!</h2>
<p id="modal-description">This modal is meant to be awesome!</p>
</ModalConent>
</Modal>
```

## 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.

### 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

### 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

### style.content
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For what purpose would someone be adding inline styles? Should we want to encourage this, vs. styling by an assigned class name?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another case of trying to mimic the react-modal API.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I share a similar concern, what would be the advantage of using inline styles over className which is also listed as one of the props available.


If this property is added, it will add inline styles to the modal content `div`.

- Type: `Object`
- Required: No

### style.overlay

If this property is added, it will add inline styles to the modal overlay `div`.

- Type: `Object`
- Required: No

### className

If this property is added, it will an additional class name to the modal content `div`.

- Type: `String`
- Required: No

### overlayClassName

If this property is added, it will an additional class name to the modal overlay `div`.

- Type: `String`
- Required: No
19 changes: 19 additions & 0 deletions components/modal/aria-helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
let appElement = null;

export function setAppElement( node ) {
if ( ! appElement ) {
appElement = node;
}
}

export function hideApp() {
if ( appElement ) {
appElement.setAttribute( 'aria-hidden', 'true' );
}
}

export function showApp() {
if ( appElement ) {
appElement.removeAttribute( 'aria-hidden' );
}
}
121 changes: 121 additions & 0 deletions components/modal/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { noop } from 'lodash';

/**
* WordPress dependencies
*/
import { Component, createPortal } from '@wordpress/element';

/**
* Internal dependencies
*/
import ModalContent from './modal-content';
import * as ariaHelper from './aria-helper';
import './style.scss';

// Used to count the number of open modals.
let modalCount = 0;

let parentElement;

class Modal extends Component {
static setAppElement( node ) {
ariaHelper.setAppElement( node );
}

static setParentElement( node ) {
if ( ! parentElement ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity, why do we need parentElement ? Why not just append and remove this.node to / from document.body directly?

Edit: I see this is to make it easier to exclude from applying aria-hidden.

parentElement = node;
}
}

componentDidMount() {
modalCount++;

if ( ! this.parentElement ) {
setElements();
}

ariaHelper.hideApp();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doing this on mount can be dangerous because it assumes developers will mount the modal properly. For example, assuming there's a controlling app with a state property showModal if they do this:
{ this.state.showModal && <Modal ...

everything works fine because the modal is not mounted on page load and aria-hidden is not set.
But
if they do just this:
<Modal ...

the modal will be mounted (but not visible) on page load, and aria-hidden will be set on the app element, with devastating consequences: the whole content of the page won't be available to assistive technologies.
I think having to require this correct usage is too fragile and prone to errors, we should try a safer way e.g.: setting aria-hidden only when isOpen is true.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fixed the logic behind this.

parentElement.appendChild( this.node );
}

componentWillUnmount() {
modalCount--;

if ( modalCount === 0 ) {
ariaHelper.showApp();
}
parentElement.removeChild( this.node );
}

render() {
const {
overlayClassName,
className,
style: {
content,
overlay,
},
children,
...otherProps
} = this.props;

if ( ! this.node ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be in the constructor. A render should have no side effects.

this.node = document.createElement( 'div' );
}

return createPortal(
<div
className={ classnames(
'components-modal__screen-overlay',
overlayClassName
) }
style={ overlay }>
<ModalContent
style={ content }
className={ classnames(
'components-modal__content',
className
) }
{ ...otherProps } >
{ children }
</ModalContent>
</div>,
this.node
);
}
}

Modal.defaultProps = {
className: null,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What value is providing a default here doing?

overlayClassName: null,
onRequestClose: noop,
focusOnMount: true,
shouldCloseOnEsc: true,
shouldCloseOnClickOutside: true,
style: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we just remove this prop? I personally don't care if react-modal has certain functionality, if we don't need it or it doesn't fit our criteria for quality (encouraging good practices, etc), it shouldn't be included.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I already removed it, but forgot to remove it from the defaultProps.

content: null,
overlay: null,
},
/* accessibility */
contentLabel: null,
aria: {
labelledby: null,
describedby: null,
},
};

function setElements() {
const wpwrapEl = document.getElementById( 'wpwrap' );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This binds usage to a specific WordPress context, eliminating reusability of the component. The component shouldn't have any awareness of its ancestry. Can we pass this data via context instead?


if ( wpwrapEl ) {
Modal.setAppElement( wpwrapEl );
Modal.setParentElement( wpwrapEl.parentNode );
}
}

export default Modal;
Loading