Skip to content

Commit

Permalink
Use Portal-based slots for the block toolbar (#16421)
Browse files Browse the repository at this point in the history
  • Loading branch information
youknowriad committed Jul 25, 2019
1 parent 9fcccb3 commit bed7e0c
Show file tree
Hide file tree
Showing 13 changed files with 107 additions and 56 deletions.
5 changes: 4 additions & 1 deletion packages/block-editor/src/components/block-list/block.js
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,10 @@ function BlockListBlock( {
onShiftSelection();
event.preventDefault();
}
} else {

// Avoid triggering multi-selection if we click toolbars/inspectors
// and all elements that are outside the Block Edit DOM tree.
} else if ( blockNodeRef.current.contains( event.target ) ) {
onSelectionStart( clientId );

// Allow user to escape out of a multi-selection to a singular
Expand Down
4 changes: 2 additions & 2 deletions packages/block-editor/src/components/block-toolbar/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ function BlockToolbar( { blockClientIds, isValid, mode } ) {
{ mode === 'visual' && isValid && (
<>
<BlockSwitcher clientIds={ blockClientIds } />
<BlockControls.Slot />
<BlockFormatControls.Slot />
<BlockControls.Slot bubblesVirtually className="block-editor-block-toolbar__slot" />
<BlockFormatControls.Slot bubblesVirtually className="block-editor-block-toolbar__slot" />
</>
) }
<BlockSettingsMenu clientIds={ blockClientIds } />
Expand Down
4 changes: 4 additions & 0 deletions packages/block-editor/src/components/block-toolbar/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,7 @@
}
}
}

.block-editor-block-toolbar__slot {
display: inline-flex;
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export default compose(
return {
isCollapsed: isCollapsed || ! isLargeViewport || (
! getSettings().hasFixedToolbar &&
getBlockRootClientId( clientId )
!! getBlockRootClientId( clientId )
),
};
} ),
Expand Down
12 changes: 11 additions & 1 deletion packages/block-editor/src/components/navigable-toolbar/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@ class NavigableToolbar extends Component {
if ( this.props.focusOnMount ) {
this.focusToolbar();
}

// We use DOM event listeners instead of React event listeners
// because we want to catch events from the underlying DOM tree
// The React Tree can be different from the DOM tree when using
// portals. Block Toolbars for instance are rendered in a separate
// React Tree.
this.toolbar.current.addEventListener( 'keydown', this.switchOnKeyDown );
}

componentwillUnmount() {
this.toolbar.current.removeEventListener( 'keydown', this.switchOnKeyDown );
}

render() {
Expand All @@ -74,7 +85,6 @@ class NavigableToolbar extends Component {
orientation="horizontal"
role="toolbar"
ref={ this.toolbar }
onKeyDown={ this.switchOnKeyDown }
{ ...omit( props, [
'focusOnMount',
] ) }
Expand Down
22 changes: 17 additions & 5 deletions packages/components/src/navigable-container/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,21 @@ class NavigableContainer extends Component {
this.getFocusableIndex = this.getFocusableIndex.bind( this );
}

componentDidMount() {
// We use DOM event listeners instead of React event listeners
// because we want to catch events from the underlying DOM tree
// The React Tree can be different from the DOM tree when using
// portals. Block Toolbars for instance are rendered in a separate
// React Trees.
this.container.addEventListener( 'keydown', this.onKeyDown );
this.container.addEventListener( 'focus', this.onFocus );
}

componentWillUnmount() {
this.container.removeEventListener( 'keydown', this.onKeyDown );
this.container.removeEventListener( 'focus', this.onFocus );
}

bindContainer( ref ) {
const { forwardedRef } = this.props;
this.container = ref;
Expand Down Expand Up @@ -73,15 +88,13 @@ class NavigableContainer extends Component {
// eventToOffset returns undefined if the event is not handled by the component
if ( offset !== undefined && stopNavigationEvents ) {
// Prevents arrow key handlers bound to the document directly interfering
event.nativeEvent.stopImmediatePropagation();
event.stopImmediatePropagation();

// When navigating a collection of items, prevent scroll containers
// from scrolling.
if ( event.target.getAttribute( 'role' ) === 'menuitem' ) {
event.preventDefault();
}

event.stopPropagation();
}

if ( ! offset ) {
Expand Down Expand Up @@ -115,8 +128,7 @@ class NavigableContainer extends Component {
'onlyBrowserTabstops',
'forwardedRef',
] ) }
onKeyDown={ this.onKeyDown }
onFocus={ this.onFocus }>
>
{ children }
</div>
);
Expand Down
13 changes: 5 additions & 8 deletions packages/components/src/navigable-container/test/menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,14 @@ function fireKeyDown( container, keyCode, shiftKey ) {
stopped: false,
};

container.simulate( 'keydown', {
stopPropagation: () => {
interaction.stopped = true;
},
preventDefault: () => {},
nativeEvent: {
stopImmediatePropagation: () => {},
},
const event = new window.KeyboardEvent( 'keydown', {
keyCode,
shiftKey,
} );
event.stopImmediatePropagation = () => {
interaction.stopped = true;
};
container.getDOMNode().dispatchEvent( event );

return interaction;
}
Expand Down
13 changes: 5 additions & 8 deletions packages/components/src/navigable-container/test/tabbable.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,14 @@ function fireKeyDown( container, keyCode, shiftKey ) {
stopped: false,
};

container.simulate( 'keydown', {
stopPropagation: () => {
interaction.stopped = true;
},
preventDefault: () => {},
nativeEvent: {
stopImmediatePropagation: () => {},
},
const event = new window.KeyboardEvent( 'keydown', {
keyCode,
shiftKey,
} );
event.stopImmediatePropagation = () => {
interaction.stopped = true;
};
container.getDOMNode().dispatchEvent( event );

return interaction;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/components/src/slot-fill/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ Both `Slot` and `Fill` accept a `name` string prop, where a `Slot` with a given
- By default, events will bubble to their parents on the DOM hierarchy (native event bubbling)
- If `bubblesVirtually` is set to true, events will bubble to their virtual parent in the React elements hierarchy instead.

`Slot` with `bubblesVirtually` set to true also accept an optional `className` to add to the slot container.

`Slot` also accepts optional `children` function prop, which takes `fills` as a param. It allows to perform additional processing and wrap `fills` conditionally.

_Example_:
Expand Down
57 changes: 45 additions & 12 deletions packages/components/src/slot-fill/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,18 @@ import { sortBy, forEach, without } from 'lodash';
/**
* WordPress dependencies
*/
import { Component, createContext } from '@wordpress/element';
import { Component, createContext, useContext, useState, useEffect } from '@wordpress/element';

const { Provider, Consumer } = createContext( {
const SlotFillContext = createContext( {
registerSlot: () => {},
unregisterSlot: () => {},
registerFill: () => {},
unregisterFill: () => {},
getSlot: () => {},
getFills: () => {},
subscribe: () => {},
} );
const { Provider, Consumer } = SlotFillContext;

class SlotFillProvider extends Component {
constructor() {
Expand All @@ -27,23 +29,26 @@ class SlotFillProvider extends Component {
this.unregisterFill = this.unregisterFill.bind( this );
this.getSlot = this.getSlot.bind( this );
this.getFills = this.getFills.bind( this );
this.subscribe = this.subscribe.bind( this );

this.slots = {};
this.fills = {};
this.state = {
this.listeners = [];
this.contextValue = {
registerSlot: this.registerSlot,
unregisterSlot: this.unregisterSlot,
registerFill: this.registerFill,
unregisterFill: this.unregisterFill,
getSlot: this.getSlot,
getFills: this.getFills,
subscribe: this.subscribe,
};
}

registerSlot( name, slot ) {
const previousSlot = this.slots[ name ];
this.slots[ name ] = slot;
this.forceUpdateFills( name );
this.triggerListeners();

// Sometimes the fills are registered after the initial render of slot
// But before the registerSlot call, we need to rerender the slot
Expand Down Expand Up @@ -75,7 +80,7 @@ class SlotFillProvider extends Component {
}

delete this.slots[ name ];
this.forceUpdateFills( name );
this.triggerListeners();
}

unregisterFill( name, instance ) {
Expand Down Expand Up @@ -106,12 +111,6 @@ class SlotFillProvider extends Component {
} );
}

forceUpdateFills( name ) {
forEach( this.fills[ name ], ( instance ) => {
instance.forceUpdate();
} );
}

forceUpdateSlot( name ) {
const slot = this.getSlot( name );

Expand All @@ -120,14 +119,48 @@ class SlotFillProvider extends Component {
}
}

triggerListeners() {
this.listeners.forEach( ( listener ) => listener() );
}

subscribe( listener ) {
this.listeners.push( listener );

return () => {
this.listeners = without( this.listeners, listener );
};
}

render() {
return (
<Provider value={ this.state }>
<Provider value={ this.contextValue }>
{ this.props.children }
</Provider>
);
}
}

/**
* React hook returning the active slot given a name.
*
* @param {string} name Slot name.
* @return {Object} Slot object.
*/
export const useSlot = ( name ) => {
const { getSlot, subscribe } = useContext( SlotFillContext );
const [ slot, setSlot ] = useState( getSlot( name ) );

useEffect( () => {
setSlot( getSlot( name ) );
const unsubscribe = subscribe( () => {
setSlot( getSlot( name ) );
} );

return unsubscribe;
}, [ name ] );

return slot;
};

export default SlotFillProvider;
export { Consumer };
17 changes: 5 additions & 12 deletions packages/components/src/slot-fill/fill.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,17 @@ import { isFunction } from 'lodash';
/**
* WordPress dependencies
*/
import { createPortal, useLayoutEffect, useRef, useState } from '@wordpress/element';
import { createPortal, useLayoutEffect, useRef } from '@wordpress/element';

/**
* Internal dependencies
*/
import { Consumer } from './context';
import { Consumer, useSlot } from './context';

let occurrences = 0;

function FillComponent( { name, getSlot, children, registerFill, unregisterFill } ) {
// Random state used to rerender the component if needed, ideally we don't need this
const [ , updateRerenderState ] = useState( {} );
const rerender = () => updateRerenderState( {} );
function FillComponent( { name, children, registerFill, unregisterFill } ) {
const slot = useSlot( name );

const ref = useRef( {
name,
Expand All @@ -30,14 +28,12 @@ function FillComponent( { name, getSlot, children, registerFill, unregisterFill
}

useLayoutEffect( () => {
ref.current.forceUpdate = rerender;
registerFill( name, ref.current );
return () => unregisterFill( name, ref.current );
}, [] );

useLayoutEffect( () => {
ref.current.children = children;
const slot = getSlot( name );
if ( slot && ! slot.props.bubblesVirtually ) {
slot.forceUpdate();
}
Expand All @@ -53,8 +49,6 @@ function FillComponent( { name, getSlot, children, registerFill, unregisterFill
registerFill( name, ref.current );
}, [ name ] );

const slot = getSlot( name );

if ( ! slot || ! slot.node || ! slot.props.bubblesVirtually ) {
return null;
}
Expand All @@ -69,10 +63,9 @@ function FillComponent( { name, getSlot, children, registerFill, unregisterFill

const Fill = ( props ) => (
<Consumer>
{ ( { getSlot, registerFill, unregisterFill } ) => (
{ ( { registerFill, unregisterFill } ) => (
<FillComponent
{ ...props }
getSlot={ getSlot }
registerFill={ registerFill }
unregisterFill={ unregisterFill }
/>
Expand Down
4 changes: 2 additions & 2 deletions packages/components/src/slot-fill/slot.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,10 @@ class SlotComponent extends Component {
}

render() {
const { children, name, bubblesVirtually = false, fillProps = {}, getFills } = this.props;
const { children, name, bubblesVirtually = false, fillProps = {}, getFills, className } = this.props;

if ( bubblesVirtually ) {
return <div ref={ this.bindNode } />;
return <div ref={ this.bindNode } className={ className } />;
}

const fills = map( getFills( name, this ), ( fill ) => {
Expand Down
Loading

0 comments on commit bed7e0c

Please sign in to comment.