diff --git a/components/clipboard-button/index.js b/components/clipboard-button/index.js
index e52ef576afc4bb..38000a308c9300 100644
--- a/components/clipboard-button/index.js
+++ b/components/clipboard-button/index.js
@@ -15,12 +15,16 @@ import { findDOMNode, Component } from '@wordpress/element';
*/
import { Button } from '../';
-class ClipboardButton extends Component {
+// This creates a container to put the textarea in which isn't removed by react
+// If react removes the textarea first, then the clipboard fails when trying to remove it
+class ClipboardContainer extends Component {
componentDidMount() {
- const { text, onCopy = noop } = this.props;
- const button = findDOMNode( this.button );
- this.clipboard = new Clipboard( button, {
+ const { text, buttonNode, onCopy = noop } = this.props;
+ this.clipboard = new Clipboard( buttonNode, {
text: () => text,
+ // If we put the textarea in a specific container, then the focus stays
+ // within this container (for use in whenFocusOutside)
+ container: this.container,
} );
this.clipboard.on( 'success', onCopy );
}
@@ -30,16 +34,40 @@ class ClipboardButton extends Component {
delete this.clipboard;
}
+ shouldComponentUpdate() {
+ return false;
+ }
+
+ render() {
+ return
this.container = ref } />;
+ }
+}
+
+class ClipboardButton extends Component {
+ constructor() {
+ super( ...arguments );
+ this.bindButton = this.bindButton.bind( this );
+ }
+
+ bindButton( ref ) {
+ if ( ref ) {
+ this.button = ref;
+ // Need to pass the button node down to use as the trigger
+ // The first rendering of ClipboardContainer it's null
+ this.forceUpdate();
+ }
+ }
render() {
- const { className, children } = this.props;
+ const { className, children, onCopy, text } = this.props;
const classes = classnames( 'components-clipboard-button', className );
return (
);
}
diff --git a/components/higher-order/with-focus-outside/index.js b/components/higher-order/with-focus-outside/index.js
new file mode 100644
index 00000000000000..15b52557572da9
--- /dev/null
+++ b/components/higher-order/with-focus-outside/index.js
@@ -0,0 +1,69 @@
+/**
+ * External dependencies
+ */
+import hoistNonReactStatic from 'hoist-non-react-statics';
+
+/**
+ * WordPress dependencies
+ */
+import { Component, findDOMNode } from '@wordpress/element';
+
+/* Heavily based on react-click-outside (https://github.com/kentor/react-click-outside/blob/master/index.js),
+ * this Higher Order Component wraps a component and fires any handleFocusOutside listeners it might have
+ * if a focus is detected ouside that component
+ *
+ * @param {WPElement} OriginalComponent the original component
+ *
+ * @return {Component} Component with focus outside detection
+ */
+
+function withFocusOutside( OriginalComponent ) {
+ const componentName = OriginalComponent.displayName || OriginalComponent.name;
+
+ class EnhancedComponent extends Component {
+ constructor() {
+ super( ...arguments );
+ this.onFocusOutside = this.onFocusOutside.bind( this );
+ this.bindRef = this.bindRef.bind( this );
+ }
+
+ componentDidMount() {
+ document.addEventListener( 'focusin', this.onFocusOutside, true );
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener( 'focusin', this.onFocusOutside, true );
+ }
+
+ onFocusOutside( e ) {
+ const domNode = this.__domNode;
+ if (
+ ( ! domNode || ! domNode.contains( e.target ) ) &&
+ typeof this.__wrappedInstance.handleFocusOutside === 'function'
+ ) {
+ this.__wrappedInstance.handleFocusOutside( e );
+ }
+ }
+
+ bindRef( ref ) {
+ this.__wrappedInstance = ref;
+ // eslint-disable-next-line react/no-find-dom-node
+ this.__domNode = findDOMNode( ref );
+ }
+
+ render() {
+ return (
+
+ );
+ }
+ }
+
+ EnhancedComponent.displayName = `FocusOutside(${ componentName })`;
+
+ return hoistNonReactStatic( EnhancedComponent, OriginalComponent );
+}
+
+export default withFocusOutside;
diff --git a/components/higher-order/with-focus-outside/test/index.js b/components/higher-order/with-focus-outside/test/index.js
new file mode 100644
index 00000000000000..1143f3e0cfc96f
--- /dev/null
+++ b/components/higher-order/with-focus-outside/test/index.js
@@ -0,0 +1,38 @@
+/**
+ * External dependencies
+ */
+import { shallow } from 'enzyme';
+import { Component } from '../../../../element';
+
+/**
+ * Internal dependencies
+ */
+import withFocusOutside from '../';
+
+class Test extends Component {
+ render() {
+ return (
+
Testing
+ );
+ }
+}
+
+describe( 'withFocusOutside()', () => {
+ const Composite = withFocusOutside( Test );
+
+ it( 'should render a basic Test component inside the HOC', () => {
+ const renderedComposite = shallow(
);
+ const wrappedElement = renderedComposite.find( Test );
+ const wrappedElementShallow = wrappedElement.shallow();
+ expect( wrappedElementShallow.hasClass( 'test' ) ).toBe( true );
+ expect( wrappedElementShallow.type() ).toBe( 'div' );
+ expect( wrappedElementShallow.text() ).toBe( 'Testing' );
+ } );
+
+ it( 'should pass additional props through to the wrapped element', () => {
+ const renderedComposite = shallow(
);
+ const wrappedElement = renderedComposite.find( Test );
+ // Ensure that the wrapped Test element has the appropriate props.
+ expect( wrappedElement.props().test ).toBe( 'test' );
+ } );
+} );
diff --git a/components/index.js b/components/index.js
index 1eb3dd2516e595..a1bba1b1aecfba 100644
--- a/components/index.js
+++ b/components/index.js
@@ -32,5 +32,6 @@ export { default as Tooltip } from './tooltip';
// Higher-Order Components
export { default as withAPIData } from './higher-order/with-api-data';
export { default as withFocusReturn } from './higher-order/with-focus-return';
+export { default as withFocusOutside } from './higher-order/with-focus-outside';
export { default as withInstanceId } from './higher-order/with-instance-id';
export { default as withSpokenMessages } from './higher-order/with-spoken-messages';
diff --git a/editor/post-permalink/index.js b/editor/post-permalink/index.js
index 2f7fc86277e431..b8c474e832efad 100644
--- a/editor/post-permalink/index.js
+++ b/editor/post-permalink/index.js
@@ -40,6 +40,8 @@ class PostPermalink extends Component {
showCopyConfirmation: false,
} );
}, 4000 );
+
+ this.props.onLinkCopied();
}
render() {
diff --git a/editor/post-title/index.js b/editor/post-title/index.js
index 5fbffe8ef1833b..63a67d8f8d8d1e 100644
--- a/editor/post-title/index.js
+++ b/editor/post-title/index.js
@@ -3,7 +3,6 @@
*/
import { connect } from 'react-redux';
import Textarea from 'react-autosize-textarea';
-import clickOutside from 'react-click-outside';
import classnames from 'classnames';
/**
@@ -12,6 +11,7 @@ import classnames from 'classnames';
import { __ } from '@wordpress/i18n';
import { Component } from '@wordpress/element';
import { keycodes } from '@wordpress/utils';
+import { withFocusOutside } from '@wordpress/components';
/**
* Internal dependencies
@@ -35,8 +35,13 @@ class PostTitle extends Component {
this.onSelect = this.onSelect.bind( this );
this.onUnselect = this.onUnselect.bind( this );
this.onSelectionChange = this.onSelectionChange.bind( this );
+ this.onContainerFocus = this.onContainerFocus.bind( this );
+ this.setFocused = this.setFocused.bind( this );
+ this.focusText = this.focusText.bind( this );
+ this.handleFocusOutside = this.handleFocusOutside.bind( this );
this.state = {
isSelected: false,
+ hasFocusWithin: false,
};
}
@@ -62,6 +67,10 @@ class PostTitle extends Component {
}
}
+ focusText() {
+ this.textareaContainer.textarea.focus();
+ }
+
onChange( event ) {
const newTitle = event.target.value.replace( REGEXP_NEWLINES, ' ' );
this.props.onUpdate( newTitle );
@@ -76,8 +85,16 @@ class PostTitle extends Component {
this.setState( { isSelected: false } );
}
- handleClickOutside() {
- this.setState( { isSelected: false } );
+ setFocused( focused ) {
+ this.setState( { hasFocusWithin: focused } );
+ }
+
+ handleFocusOutside() {
+ this.setFocused( false );
+ }
+
+ onContainerFocus() {
+ this.setFocused( true );
}
onKeyDown( event ) {
@@ -88,12 +105,14 @@ class PostTitle extends Component {
render() {
const { title } = this.props;
- const { isSelected } = this.state;
- const className = classnames( 'editor-post-title', { 'is-selected': isSelected } );
+ const { isSelected, hasFocusWithin } = this.state;
+ const className = classnames( 'editor-post-title', { 'is-selected': isSelected && hasFocusWithin } );
return (
-
- { isSelected &&
}
+
+ { isSelected && hasFocusWithin &&
}