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 && }