diff --git a/packages/block-editor/src/components/block-list/block.js b/packages/block-editor/src/components/block-list/block.js index 320f63555946c..c7632b0bc7168 100644 --- a/packages/block-editor/src/components/block-list/block.js +++ b/packages/block-editor/src/components/block-list/block.js @@ -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 diff --git a/packages/block-editor/src/components/block-toolbar/index.js b/packages/block-editor/src/components/block-toolbar/index.js index 4eb26c2d50f86..83edd7814a0ed 100644 --- a/packages/block-editor/src/components/block-toolbar/index.js +++ b/packages/block-editor/src/components/block-toolbar/index.js @@ -31,8 +31,8 @@ function BlockToolbar( { blockClientIds, isValid, mode } ) { { mode === 'visual' && isValid && ( <> - - + + ) } diff --git a/packages/block-editor/src/components/block-toolbar/style.scss b/packages/block-editor/src/components/block-toolbar/style.scss index 3d741214e9879..7d9f29c956e62 100644 --- a/packages/block-editor/src/components/block-toolbar/style.scss +++ b/packages/block-editor/src/components/block-toolbar/style.scss @@ -42,3 +42,7 @@ } } } + +.block-editor-block-toolbar__slot { + display: inline-flex; +} diff --git a/packages/block-editor/src/components/block-vertical-alignment-toolbar/index.js b/packages/block-editor/src/components/block-vertical-alignment-toolbar/index.js index d3d94a0218b09..86414d5ddf07f 100644 --- a/packages/block-editor/src/components/block-vertical-alignment-toolbar/index.js +++ b/packages/block-editor/src/components/block-vertical-alignment-toolbar/index.js @@ -72,7 +72,7 @@ export default compose( return { isCollapsed: isCollapsed || ! isLargeViewport || ( ! getSettings().hasFixedToolbar && - getBlockRootClientId( clientId ) + !! getBlockRootClientId( clientId ) ), }; } ), diff --git a/packages/block-editor/src/components/navigable-toolbar/index.js b/packages/block-editor/src/components/navigable-toolbar/index.js index b4fbbf7f3f241..21c6b5dd841e4 100644 --- a/packages/block-editor/src/components/navigable-toolbar/index.js +++ b/packages/block-editor/src/components/navigable-toolbar/index.js @@ -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() { @@ -74,7 +85,6 @@ class NavigableToolbar extends Component { orientation="horizontal" role="toolbar" ref={ this.toolbar } - onKeyDown={ this.switchOnKeyDown } { ...omit( props, [ 'focusOnMount', ] ) } diff --git a/packages/components/src/navigable-container/container.js b/packages/components/src/navigable-container/container.js index 244d646de026f..be3a9d73be402 100644 --- a/packages/components/src/navigable-container/container.js +++ b/packages/components/src/navigable-container/container.js @@ -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; @@ -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 ) { @@ -115,8 +128,7 @@ class NavigableContainer extends Component { 'onlyBrowserTabstops', 'forwardedRef', ] ) } - onKeyDown={ this.onKeyDown } - onFocus={ this.onFocus }> + > { children } ); diff --git a/packages/components/src/navigable-container/test/menu.js b/packages/components/src/navigable-container/test/menu.js index 7dff49f321a13..8f19afe270cb6 100644 --- a/packages/components/src/navigable-container/test/menu.js +++ b/packages/components/src/navigable-container/test/menu.js @@ -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; } diff --git a/packages/components/src/navigable-container/test/tabbable.js b/packages/components/src/navigable-container/test/tabbable.js index b56421df41bb3..65bfbb387c2f3 100644 --- a/packages/components/src/navigable-container/test/tabbable.js +++ b/packages/components/src/navigable-container/test/tabbable.js @@ -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; } diff --git a/packages/components/src/slot-fill/README.md b/packages/components/src/slot-fill/README.md index 23bbd7b77f941..20cd6d708bea6 100644 --- a/packages/components/src/slot-fill/README.md +++ b/packages/components/src/slot-fill/README.md @@ -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_: diff --git a/packages/components/src/slot-fill/context.js b/packages/components/src/slot-fill/context.js index 7ee0d9806c400..587c1ccab5ccb 100644 --- a/packages/components/src/slot-fill/context.js +++ b/packages/components/src/slot-fill/context.js @@ -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() { @@ -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 @@ -75,7 +80,7 @@ class SlotFillProvider extends Component { } delete this.slots[ name ]; - this.forceUpdateFills( name ); + this.triggerListeners(); } unregisterFill( name, instance ) { @@ -106,12 +111,6 @@ class SlotFillProvider extends Component { } ); } - forceUpdateFills( name ) { - forEach( this.fills[ name ], ( instance ) => { - instance.forceUpdate(); - } ); - } - forceUpdateSlot( name ) { const slot = this.getSlot( name ); @@ -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 ( - + { this.props.children } ); } } +/** + * 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 }; diff --git a/packages/components/src/slot-fill/fill.js b/packages/components/src/slot-fill/fill.js index a0c2876643f04..3600e177e5434 100644 --- a/packages/components/src/slot-fill/fill.js +++ b/packages/components/src/slot-fill/fill.js @@ -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, @@ -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(); } @@ -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; } @@ -69,10 +63,9 @@ function FillComponent( { name, getSlot, children, registerFill, unregisterFill const Fill = ( props ) => ( - { ( { getSlot, registerFill, unregisterFill } ) => ( + { ( { registerFill, unregisterFill } ) => ( diff --git a/packages/components/src/slot-fill/slot.js b/packages/components/src/slot-fill/slot.js index 6de69f74f865a..4578656b5efaa 100644 --- a/packages/components/src/slot-fill/slot.js +++ b/packages/components/src/slot-fill/slot.js @@ -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
; + return
; } const fills = map( getFills( name, this ), ( fill ) => { diff --git a/packages/e2e-tests/specs/block-hierarchy-navigation.test.js b/packages/e2e-tests/specs/block-hierarchy-navigation.test.js index 3f0e1c8c3f55c..378180b58cd82 100644 --- a/packages/e2e-tests/specs/block-hierarchy-navigation.test.js +++ b/packages/e2e-tests/specs/block-hierarchy-navigation.test.js @@ -23,7 +23,7 @@ describe( 'Navigating the block hierarchy', () => { await page.click( '[aria-label="Two columns; equal split"]' ); // Add a paragraph in the first column. - await pressKeyTimes( 'Tab', 5 ); // Tab to inserter. + await pressKeyTimes( 'Tab', 3 ); // Tab to inserter. await page.keyboard.press( 'Enter' ); // Activate inserter. await page.keyboard.type( 'Paragraph' ); await pressKeyTimes( 'Tab', 3 ); // Tab to paragraph result. @@ -50,7 +50,7 @@ describe( 'Navigating the block hierarchy', () => { await lastColumnsBlockMenuItem.click(); // Insert text in the last column block. - await pressKeyTimes( 'Tab', 5 ); // Tab to inserter. + await pressKeyTimes( 'Tab', 3 ); // Tab to inserter. await page.keyboard.press( 'Enter' ); // Activate inserter. await page.keyboard.type( 'Paragraph' ); await pressKeyTimes( 'Tab', 3 ); // Tab to paragraph result. @@ -65,7 +65,7 @@ describe( 'Navigating the block hierarchy', () => { await page.click( '[aria-label="Two columns; equal split"]' ); // Add a paragraph in the first column. - await pressKeyTimes( 'Tab', 5 ); // Tab to inserter. + await pressKeyTimes( 'Tab', 3 ); // Tab to inserter. await page.keyboard.press( 'Enter' ); // Activate inserter. await page.keyboard.type( 'Paragraph' ); await pressKeyTimes( 'Tab', 3 ); // Tab to paragraph result. @@ -92,7 +92,7 @@ describe( 'Navigating the block hierarchy', () => { await page.keyboard.press( 'Enter' ); // Insert text in the last column block - await pressKeyTimes( 'Tab', 5 ); // Tab to inserter. + await pressKeyTimes( 'Tab', 3 ); // Tab to inserter. await page.keyboard.press( 'Enter' ); // Activate inserter. await page.keyboard.type( 'Paragraph' ); await pressKeyTimes( 'Tab', 3 ); // Tab to paragraph result.