diff --git a/blocks/editable/index.js b/blocks/editable/index.js index 7198db9f50d83a..f79154570ec74e 100644 --- a/blocks/editable/index.js +++ b/blocks/editable/index.js @@ -12,6 +12,7 @@ import { identity, find, defer, + pickBy, noop, } from 'lodash'; import { nodeListToReact } from 'dom-react'; @@ -74,6 +75,8 @@ export default class Editable extends Component { ); } + this.bindMirrorRef = this.bindNodeRef.bind( this, 'mirror' ); + this.bindEditorRef = this.bindNodeRef.bind( this, 'editor' ); this.onInit = this.onInit.bind( this ); this.getSettings = this.getSettings.bind( this ); this.onSetup = this.onSetup.bind( this ); @@ -89,6 +92,8 @@ export default class Editable extends Component { this.onBeforePastePreProcess = this.onBeforePastePreProcess.bind( this ); this.onPaste = this.onPaste.bind( this ); + this.nodeRefs = {}; + this.state = { formats: {}, empty: ! value || ! value.length, @@ -96,6 +101,10 @@ export default class Editable extends Component { }; } + bindNodeRef( name, node ) { + this.nodeRefs[ name ] = node; + } + getSettings( settings ) { return ( this.props.getSettings || identity )( { ...settings, @@ -143,6 +152,9 @@ export default class Editable extends Component { onInit() { this.updateFocus(); + + // Start mirroring attributes after editor initializes + this.nodeRefs.editor.mirrorNode( this.nodeRefs.mirror ); } onFocus() { @@ -594,7 +606,6 @@ export default class Editable extends Component { render() { const { tagName: Tagname = 'div', - style, value, focus, wrapperClassname, @@ -606,6 +617,19 @@ export default class Editable extends Component { keepPlaceholderOnFocus = false, } = this.props; + // Assign as props to editor node those not explicitly handled by + // Editable. Since event handlers are proxied by TinyMCE, ignore any + // props prefixed by `on` (see: `proxyPropHandler`). + const editorProps = pickBy( this.props, ( propValue, propKey ) => ( + ! this.constructor.propTypes.hasOwnProperty( propKey ) && + ! /^on[A-Z]/.test( propKey ) + ) ); + + editorProps.className = classnames( 'blocks-editable__tinymce', className ); + if ( ! editorProps[ 'aria-label' ] ) { + editorProps[ 'aria-label' ] = placeholder; + } + // Generating a key that includes `tagName` ensures that if the tag // changes, we unmount and destroy the previous TinyMCE element, then // mount and initialize a new child element in its place. @@ -635,30 +659,49 @@ export default class Editable extends Component { { formatToolbar } } +
+ { isPlaceholderVisible && + + { MultilineTag ? { placeholder } : placeholder } + + } - { isPlaceholderVisible && - - { MultilineTag ? { placeholder } : placeholder } - - }
); } } +Editable.propTypes = { + className: noop, + focus: noop, + formattingControls: noop, + getSettings: noop, + inlineToolbar: noop, + keepPlaceholderOnFocus: noop, + multiline: noop, + onChange: noop, + onFocus: noop, + onMerge: noop, + onReplace: noop, + onSetup: noop, + placeholder: noop, + tagName: noop, + value: noop, + wrapperClassname: noop, +}; + Editable.contextTypes = { onUndo: noop, }; diff --git a/blocks/editable/style.scss b/blocks/editable/style.scss index 7fa2bf97d44738..365c343a73eb32 100644 --- a/blocks/editable/style.scss +++ b/blocks/editable/style.scss @@ -26,6 +26,26 @@ color: $blue-medium-500; } + &[data-mirror] { + display: none; + } + + &[data-placeholder] { + opacity: 0.5; + pointer-events: none; + + & + .blocks-editable__tinymce { + position: absolute; + top: 0; + width: 100%; + margin-top: 0; + + & > p { + margin-top: 0; + } + } + } + &:focus a[data-mce-selected] { padding: 0 2px; margin: 0 -2px; @@ -46,22 +66,6 @@ &:focus code[data-mce-selected] { background: $light-gray-400; } - - &[data-is-placeholder-visible="true"] { - position: absolute; - top: 0; - width: 100%; - margin-top: 0; - - & > p { - margin-top: 0; - } - } - - & + .blocks-editable__tinymce { - opacity: 0.5; - pointer-events: none; - } } .has-drop-cap .blocks-editable__tinymce:not( :focus ) { diff --git a/blocks/editable/tinymce.js b/blocks/editable/tinymce.js index 415a388c27a4ec..0d73b0eb441651 100644 --- a/blocks/editable/tinymce.js +++ b/blocks/editable/tinymce.js @@ -2,8 +2,6 @@ * External dependencies */ import tinymce from 'tinymce'; -import { isEqual } from 'lodash'; -import classnames from 'classnames'; /** * WordPress dependencies @@ -15,6 +13,17 @@ export default class TinyMCE extends Component { this.initialize(); } + componentWillUnmount() { + if ( this.observer ) { + this.observer.disconnect(); + } + + if ( this.editor ) { + this.editor.destroy(); + delete this.editor; + } + } + shouldComponentUpdate() { // We must prevent rerenders because TinyMCE will modify the DOM, thus // breaking React's ability to reconcile changes. @@ -23,31 +32,26 @@ export default class TinyMCE extends Component { return false; } - componentWillReceiveProps( nextProps ) { - const name = 'data-is-placeholder-visible'; - const isPlaceholderVisible = String( !! nextProps.isPlaceholderVisible ); - - if ( this.editorNode.getAttribute( name ) !== isPlaceholderVisible ) { - this.editorNode.setAttribute( name, isPlaceholderVisible ); - } - - if ( ! isEqual( this.props.style, nextProps.style ) ) { - this.editorNode.setAttribute( 'style', '' ); - Object.assign( this.editorNode.style, nextProps.style ); - } - - if ( ! isEqual( this.props.className, nextProps.className ) ) { - this.editorNode.className = classnames( nextProps.className, 'blocks-editable__tinymce' ); - } - } - - componentWillUnmount() { - if ( ! this.editor ) { - return; - } + mirrorNode( node ) { + // Since React reconciliation is disabled for the TinyMCE node, we sync + // attribute changes using a mutation observer on a node within the + // parent Editable which receives reconciliation from Editable props. + this.observer = new window.MutationObserver( ( mutations ) => { + mutations.forEach( ( mutation ) => { + const { attributeName } = mutation; + const nextValue = node.getAttribute( attributeName ); + + if ( null === nextValue ) { + this.editorNode.removeAttribute( attributeName ); + } else { + this.editorNode.setAttribute( attributeName, nextValue ); + } + } ); + } ); - this.editor.destroy(); - delete this.editor; + this.observer.observe( node, { + attributes: true, + } ); } initialize() { @@ -83,7 +87,7 @@ export default class TinyMCE extends Component { } render() { - const { tagName = 'div', style, defaultValue, label, className } = this.props; + const { tagName = 'div', defaultValue, additionalProps } = this.props; // If a default value is provided, render it into the DOM even before // TinyMCE finishes initializing. This avoids a short delay by allowing @@ -97,9 +101,7 @@ export default class TinyMCE extends Component { ref: ( node ) => this.editorNode = node, contentEditable: true, suppressContentEditableWarning: true, - className: classnames( className, 'blocks-editable__tinymce' ), - style, - 'aria-label': label, + ...additionalProps, }, children ); } } diff --git a/components/autocomplete/index.js b/components/autocomplete/index.js index 9247198130ce78..495ed19b5b831a 100644 --- a/components/autocomplete/index.js +++ b/components/autocomplete/index.js @@ -16,10 +16,11 @@ import { keycodes } from '@wordpress/utils'; import './style.scss'; import Button from '../button'; import Popover from '../popover'; +import withInstanceId from '../higher-order/with-instance-id'; const { ENTER, ESCAPE, UP, DOWN } = keycodes; -class Autocomplete extends Component { +export class Autocomplete extends Component { static getInitialState() { return { isOpen: false, @@ -180,10 +181,12 @@ class Autocomplete extends Component { } render() { - const { children, className } = this.props; + const { children, className, instanceId } = this.props; const { isOpen, selectedIndex } = this.state; const classes = classnames( 'components-autocomplete__popover', className ); const filteredOptions = this.getFilteredOptions(); + const listBoxId = `components-autocomplete-listbox-${ instanceId }`; + const activeId = `components-autocomplete-item-${ instanceId }-${ selectedIndex }`; // Blur is applied to the wrapper node, since if the child is Editable, // the event will not have `relatedTarget` assigned. @@ -196,6 +199,10 @@ class Autocomplete extends Component { { cloneElement( Children.only( children ), { onInput: this.search, onKeyDown: this.setSelectedIndex, + role: 'combobox', + 'aria-expanded': isOpen, + 'aria-activedescendant': isOpen ? activeId : null, + 'aria-owns': isOpen ? listBoxId : null, } ) } 0 } @@ -204,13 +211,15 @@ class Autocomplete extends Component { className={ classes } >