Skip to content

Commit

Permalink
Blocks: (Re-)enable selection of embed block by clicking (#5730)
Browse files Browse the repository at this point in the history
* Components: Add withGlobalEvents higher-order component

* Components: Add FocusableIframe component

* Block List: Enable blocks to programatically select themselves

* Components: Render sandbox as FocusableIFrame

* Blocks: Select embed block on sandbox focus

* Element: Expose React.createRef

* Components: Use withGlobalEvents for Sandbox message listening

* Components: Dispatch focus event on FocusableIframe
  • Loading branch information
aduth committed Apr 13, 2018
1 parent 4c0521e commit 972788c
Show file tree
Hide file tree
Showing 12 changed files with 487 additions and 34 deletions.
2 changes: 2 additions & 0 deletions blocks/library/embed/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ function getEmbedBlockSettings( { title, icon, category = 'embed', transforms, k
edit: class extends Component {
constructor() {
super( ...arguments );

this.doServerSideRender = this.doServerSideRender.bind( this );

this.state = {
html: '',
type: '',
Expand Down
39 changes: 39 additions & 0 deletions components/focusable-iframe/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
Focusable Iframe
================

`<FocusableIframe />` is a component rendering an `iframe` element enhanced to support focus events. By default, it is not possible to detect when an iframe is focused or clicked within. This enhanced component uses a technique which checks whether the target of a window `blur` event is the iframe, inferring that this has resulted in the focus of the iframe. It dispatches an emulated [`FocusEvent`](https://developer.mozilla.org/en-US/docs/Web/API/FocusEvent) on the iframe element with event bubbling, so a parent component binding its own `onFocus` event will account for focus transitioning within the iframe.

## Usage

Use as you would a standard `iframe`. You may pass `onFocus` directly as the callback to be invoked when the iframe receives focus, or on an ancestor component since the event will bubble.

```jsx
import { FocusableIframe } from '@wordpress/components';

function MyIframe() {
return (
<FocusableIframe
src="https://example.com"
onFocus={ /* ... */ }
/>
);
}
```

## Props

Any props aside from those listed below will be passed to the `FocusableIframe` will be passed through to the underlying `iframe` element.

### `onFocus`

- Type: `Function`
- Required: No

Callback to invoke when iframe receives focus. Passes an emulated `FocusEvent` object as the first argument.

### `iframeRef`

- Type: `wp.element.Ref`
- Required: No

If a reference to the underlying DOM element is needed, pass `iframeRef` as the result of a `wp.element.createRef` called from your component.
69 changes: 69 additions & 0 deletions components/focusable-iframe/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* External dependencies
*/
import { omit } from 'lodash';

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

/**
* Internal dependencies
*/
import withGlobalEvents from '../higher-order/with-global-events';

/**
* Browser dependencies
*/

const { FocusEvent } = window;

class FocusableIframe extends Component {
constructor( props ) {
super( ...arguments );

this.checkFocus = this.checkFocus.bind( this );

this.node = props.iframeRef || createRef();
}

/**
* Checks whether the iframe is the activeElement, inferring that it has
* then received focus, and calls the `onFocus` prop callback.
*/
checkFocus() {
const iframe = this.node.current;

if ( document.activeElement !== iframe ) {
return;
}

const focusEvent = new FocusEvent( 'focus', { bubbles: true } );
iframe.dispatchEvent( focusEvent );

const { onFocus } = this.props;
if ( onFocus ) {
onFocus( focusEvent );
}
}

render() {
// Disable reason: The rendered iframe is a pass-through component,
// assigning props inherited from the rendering parent. It's the
// responsibility of the parent to assign a title.

/* eslint-disable jsx-a11y/iframe-has-title */
return (
<iframe
ref={ this.node }
{ ...omit( this.props, [ 'iframeRef', 'onFocus' ] ) }
/>
);
/* eslint-enable jsx-a11y/iframe-has-title */
}
}

export default withGlobalEvents( {
blur: 'checkFocus',
} )( FocusableIframe );
31 changes: 31 additions & 0 deletions components/higher-order/with-global-events/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
withGlobalEvents
================

`withGlobalEvents` is a higher-order component used to facilitate responding to global events, where one would otherwise use `window.addEventListener`.

On behalf of the consuming developer, the higher-order component manages:

- Unbinding when the component unmounts.
- Binding at most a single event handler for the entire application.

## Usage

Pass an object where keys correspond to the DOM event type, the value the name of the method on the original component's instance which handles the event.

```js
import { withGlobalEvents } from '@wordpress/components';

class ResizingComponent extends Component {
handleResize() {
// ...
}

render() {
// ...
}
}

export default withGlobalEvents( {
resize: 'handleResize',
} )( ResizingComponent );
```
64 changes: 64 additions & 0 deletions components/higher-order/with-global-events/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* External dependencies
*/
import { forEach } from 'lodash';

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

/**
* Internal dependencies
*/
import Listener from './listener';

/**
* Listener instance responsible for managing document event handling.
*
* @type {Listener}
*/
const listener = new Listener();

function withGlobalEvents( eventTypesToHandlers ) {
return createHigherOrderComponent( ( WrappedComponent ) => {
return class extends Component {
constructor() {
super( ...arguments );

this.handleEvent = this.handleEvent.bind( this );

this.ref = createRef();
}

componentDidMount() {
forEach( eventTypesToHandlers, ( handler, eventType ) => {
listener.add( eventType, this );
} );
}

componentWillUnmount() {
forEach( eventTypesToHandlers, ( handler, eventType ) => {
listener.remove( eventType, this );
} );
}

handleEvent( event ) {
const handler = eventTypesToHandlers[ event.type ];
if ( typeof this.ref.current[ handler ] === 'function' ) {
this.ref.current[ handler ]( event );
}
}

render() {
return <WrappedComponent ref={ this.ref } { ...this.props } />;
}
};
}, 'withGlobalEvents' );
}

export default withGlobalEvents;
45 changes: 45 additions & 0 deletions components/higher-order/with-global-events/listener.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* External dependencies
*/
import { forEach, without } from 'lodash';

/**
* Class responsible for orchestrating event handling on the global window,
* binding a single event to be shared across all handling instances, and
* removing the handler when no instances are listening for the event.
*/
class Listener {
constructor() {
this.listeners = {};

this.handleEvent = this.handleEvent.bind( this );
}

add( eventType, instance ) {
if ( ! this.listeners[ eventType ] ) {
// Adding first listener for this type, so bind event.
window.addEventListener( eventType, this.handleEvent );
this.listeners[ eventType ] = [];
}

this.listeners[ eventType ].push( instance );
}

remove( eventType, instance ) {
this.listeners[ eventType ] = without( this.listeners[ eventType ], instance );

if ( ! this.listeners[ eventType ].length ) {
// Removing last listener for this type, so unbind event.
window.removeEventListener( eventType, this.handleEvent );
delete this.listeners[ eventType ];
}
}

handleEvent( event ) {
forEach( this.listeners[ event.type ], ( instance ) => {
instance.handleEvent( event );
} );
}
}

export default Listener;
93 changes: 93 additions & 0 deletions components/higher-order/with-global-events/test/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* External dependencies
*/
import { mount } from 'enzyme';

/**
* External dependencies
*/
import { Component } from '@wordpress/element';

/**
* Internal dependencies
*/
import withGlobalEvents from '../';
import Listener from '../listener';

jest.mock( '../listener', () => {
const ActualListener = require.requireActual( '../listener' ).default;

return class extends ActualListener {
constructor() {
super( ...arguments );

this.constructor._instance = this;

jest.spyOn( this, 'add' );
jest.spyOn( this, 'remove' );
}
};
} );

describe( 'withGlobalEvents', () => {
let wrapper;

class OriginalComponent extends Component {
handleResize( event ) {
this.props.onResize( event );
}

render() {
return <div>{ this.props.children }</div>;
}
}

beforeAll( () => {
jest.spyOn( OriginalComponent.prototype, 'handleResize' );
} );

beforeEach( () => {
jest.clearAllMocks();
} );

afterEach( () => {
if ( wrapper ) {
wrapper.unmount();
wrapper = null;
}
} );

function mountEnhancedComponent( props ) {
const EnhancedComponent = withGlobalEvents( {
resize: 'handleResize',
} )( OriginalComponent );

wrapper = mount( <EnhancedComponent { ...props }>Hello</EnhancedComponent> );
}

it( 'renders with original component', () => {
mountEnhancedComponent();

expect( wrapper.childAt( 0 ).childAt( 0 ).type() ).toBe( 'div' );
expect( wrapper.childAt( 0 ).text() ).toBe( 'Hello' );
} );

it( 'binds events from passed object', () => {
mountEnhancedComponent();

expect( Listener._instance.add ).toHaveBeenCalledWith( 'resize', wrapper.instance() );
} );

it( 'handles events', () => {
const onResize = jest.fn();

mountEnhancedComponent( { onResize } );

const event = { type: 'resize' };

Listener._instance.handleEvent( event );

expect( OriginalComponent.prototype.handleResize ).toHaveBeenCalledWith( event );
expect( onResize ).toHaveBeenCalledWith( event );
} );
} );
Loading

0 comments on commit 972788c

Please sign in to comment.