diff --git a/blocks/library/embed/index.js b/blocks/library/embed/index.js
index 5b0c80628e6f27..51f8e7c2d94d2c 100644
--- a/blocks/library/embed/index.js
+++ b/blocks/library/embed/index.js
@@ -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: '',
diff --git a/components/focusable-iframe/README.md b/components/focusable-iframe/README.md
new file mode 100644
index 00000000000000..b1b09a74974a01
--- /dev/null
+++ b/components/focusable-iframe/README.md
@@ -0,0 +1,39 @@
+Focusable Iframe
+================
+
+`` 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 (
+
+ );
+}
+```
+
+## 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.
diff --git a/components/focusable-iframe/index.js b/components/focusable-iframe/index.js
new file mode 100644
index 00000000000000..2d6a1a2b177655
--- /dev/null
+++ b/components/focusable-iframe/index.js
@@ -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 (
+
+ );
+ /* eslint-enable jsx-a11y/iframe-has-title */
+ }
+}
+
+export default withGlobalEvents( {
+ blur: 'checkFocus',
+} )( FocusableIframe );
diff --git a/components/higher-order/with-global-events/README.md b/components/higher-order/with-global-events/README.md
new file mode 100644
index 00000000000000..7bcf4c3044d057
--- /dev/null
+++ b/components/higher-order/with-global-events/README.md
@@ -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 );
+```
diff --git a/components/higher-order/with-global-events/index.js b/components/higher-order/with-global-events/index.js
new file mode 100644
index 00000000000000..a04b1d8020b351
--- /dev/null
+++ b/components/higher-order/with-global-events/index.js
@@ -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 ;
+ }
+ };
+ }, 'withGlobalEvents' );
+}
+
+export default withGlobalEvents;
diff --git a/components/higher-order/with-global-events/listener.js b/components/higher-order/with-global-events/listener.js
new file mode 100644
index 00000000000000..fa469e7ce1a0da
--- /dev/null
+++ b/components/higher-order/with-global-events/listener.js
@@ -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;
diff --git a/components/higher-order/with-global-events/test/index.js b/components/higher-order/with-global-events/test/index.js
new file mode 100644
index 00000000000000..9054e5515d2524
--- /dev/null
+++ b/components/higher-order/with-global-events/test/index.js
@@ -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
{ this.props.children }
;
+ }
+ }
+
+ 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( Hello );
+ }
+
+ 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 );
+ } );
+} );
diff --git a/components/higher-order/with-global-events/test/listener.js b/components/higher-order/with-global-events/test/listener.js
new file mode 100644
index 00000000000000..53f5db9a3298de
--- /dev/null
+++ b/components/higher-order/with-global-events/test/listener.js
@@ -0,0 +1,87 @@
+/**
+ * Internal dependencies
+ */
+import Listener from '../listener';
+
+describe( 'Listener', () => {
+ const createHandler = () => ( { handleEvent: jest.fn() } );
+
+ let listener, _addEventListener, _removeEventListener;
+ beforeAll( () => {
+ _addEventListener = global.window.addEventListener;
+ _removeEventListener = global.window.removeEventListener;
+ global.window.addEventListener = jest.fn();
+ global.window.removeEventListener = jest.fn();
+ } );
+
+ beforeEach( () => {
+ listener = new Listener();
+ jest.clearAllMocks();
+ } );
+
+ afterAll( () => {
+ global.window.addEventListener = _addEventListener;
+ global.window.removeEventListener = _removeEventListener;
+ } );
+
+ describe( '#add()', () => {
+ it( 'adds an event listener on first listener', () => {
+ listener.add( 'resize', createHandler() );
+
+ expect( window.addEventListener ).toHaveBeenCalledWith( 'resize', expect.any( Function ) );
+ } );
+
+ it( 'does not add event listener on subsequent listeners', () => {
+ listener.add( 'resize', createHandler() );
+ listener.add( 'resize', createHandler() );
+
+ expect( window.addEventListener ).toHaveBeenCalledTimes( 1 );
+ } );
+ } );
+
+ describe( '#remove()', () => {
+ it( 'removes an event listener on last listener', () => {
+ const handler = createHandler();
+ listener.add( 'resize', handler );
+ listener.remove( 'resize', handler );
+
+ expect( window.removeEventListener ).toHaveBeenCalledWith( 'resize', expect.any( Function ) );
+ } );
+
+ it( 'does not remove event listener on remaining listeners', () => {
+ const firstHandler = createHandler();
+ const secondHandler = createHandler();
+ listener.add( 'resize', firstHandler );
+ listener.add( 'resize', secondHandler );
+ listener.remove( 'resize', firstHandler );
+
+ expect( window.removeEventListener ).not.toHaveBeenCalled();
+ } );
+ } );
+
+ describe( '#handleEvent()', () => {
+ it( 'calls concerned listeners', () => {
+ const handler = createHandler();
+ listener.add( 'resize', handler );
+
+ const event = { type: 'resize' };
+
+ listener.handleEvent( event );
+
+ expect( handler.handleEvent ).toHaveBeenCalledWith( event );
+ } );
+
+ it( 'calls all added handlers', () => {
+ const handler = createHandler();
+ listener.add( 'resize', handler );
+ listener.add( 'resize', handler );
+ listener.add( 'resize', handler );
+
+ const event = { type: 'resize' };
+
+ listener.handleEvent( event );
+
+ expect( handler.handleEvent ).toHaveBeenCalledTimes( 3 );
+ } );
+ } );
+} );
diff --git a/components/index.js b/components/index.js
index c50b14168b770f..38731f65401287 100644
--- a/components/index.js
+++ b/components/index.js
@@ -16,6 +16,7 @@ export { default as DropZoneProvider } from './drop-zone/provider';
export { default as Dropdown } from './dropdown';
export { default as DropdownMenu } from './dropdown-menu';
export { default as ExternalLink } from './external-link';
+export { default as FocusableIframe } from './focusable-iframe';
export { default as FormFileUpload } from './form-file-upload';
export { default as FormToggle } from './form-toggle';
export { default as FormTokenField } from './form-token-field';
@@ -60,6 +61,7 @@ export { default as withFallbackStyles } from './higher-order/with-fallback-styl
export { default as withFilters } from './higher-order/with-filters';
export { default as withFocusOutside } from './higher-order/with-focus-outside';
export { default as withFocusReturn } from './higher-order/with-focus-return';
+export { default as withGlobalEvents } from './higher-order/with-global-events';
export { default as withInstanceId } from './higher-order/with-instance-id';
export { default as withSafeTimeout } from './higher-order/with-safe-timeout';
export { default as withSpokenMessages } from './higher-order/with-spoken-messages';
diff --git a/components/sandbox/index.js b/components/sandbox/index.js
index c75fdf73cc3ece..840bbaa94f7210 100644
--- a/components/sandbox/index.js
+++ b/components/sandbox/index.js
@@ -1,15 +1,22 @@
/**
* WordPress dependencies
*/
-import { Component, renderToString } from '@wordpress/element';
+import { Component, renderToString, createRef } from '@wordpress/element';
-export default class Sandbox extends Component {
+/**
+ * Internal dependencies
+ */
+import FocusableIframe from '../focusable-iframe';
+import withGlobalEvents from '../higher-order/with-global-events';
+
+class Sandbox extends Component {
constructor() {
super( ...arguments );
this.trySandbox = this.trySandbox.bind( this );
this.checkMessageForResize = this.checkMessageForResize.bind( this );
- this.checkFocus = this.checkFocus.bind( this );
+
+ this.iframe = createRef();
this.state = {
width: 0,
@@ -17,16 +24,24 @@ export default class Sandbox extends Component {
};
}
+ componentDidMount() {
+ this.trySandbox();
+ }
+
+ componentDidUpdate() {
+ this.trySandbox();
+ }
+
isFrameAccessible() {
try {
- return !! this.iframe.contentDocument.body;
+ return !! this.iframe.current.contentDocument.body;
} catch ( e ) {
return false;
}
}
checkMessageForResize( event ) {
- const iframe = this.iframe;
+ const iframe = this.iframe.current;
// Attempt to parse the message data as JSON if passed as string
let data = event.data || {};
@@ -51,33 +66,12 @@ export default class Sandbox extends Component {
}
}
- componentDidMount() {
- window.addEventListener( 'message', this.checkMessageForResize, false );
- window.addEventListener( 'blur', this.checkFocus );
- this.trySandbox();
- }
-
- componentDidUpdate() {
- this.trySandbox();
- }
-
- componentWillUnmount() {
- window.removeEventListener( 'message', this.checkMessageForResize );
- window.removeEventListener( 'blur', this.checkFocus );
- }
-
- checkFocus() {
- if ( this.props.onFocus && document.activeElement === this.iframe ) {
- this.props.onFocus();
- }
- }
-
trySandbox() {
if ( ! this.isFrameAccessible() ) {
return;
}
- const body = this.iframe.contentDocument.body;
+ const body = this.iframe.current.contentDocument.body;
if ( null !== body.getAttribute( 'data-resizable-iframe-connected' ) ) {
return;
}
@@ -172,9 +166,10 @@ export default class Sandbox extends Component {
// writing the document like this makes it act in the same way as if it was
// loaded over the network, so DOM creation and mutation, script execution, etc.
// all work as expected
- this.iframe.contentWindow.document.open();
- this.iframe.contentWindow.document.write( '' + renderToString( htmlDoc ) );
- this.iframe.contentWindow.document.close();
+ const iframeDocument = this.iframe.current.contentWindow.document;
+ iframeDocument.open();
+ iframeDocument.write( '' + renderToString( htmlDoc ) );
+ iframeDocument.close();
}
static get defaultProps() {
@@ -185,10 +180,12 @@ export default class Sandbox extends Component {
}
render() {
+ const { title } = this.props;
+
return (
-