-
Notifications
You must be signed in to change notification settings - Fork 4.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Blocks: (Re-)enable selection of embed block by clicking (#5730)
* 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
Showing
12 changed files
with
487 additions
and
34 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 ); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 ); | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 ); | ||
} ); | ||
} ); |
Oops, something went wrong.