diff --git a/packages/block-editor/src/components/block-inspector/index.js b/packages/block-editor/src/components/block-inspector/index.js index 0b15ae17665ee..584b600bc4d7d 100644 --- a/packages/block-editor/src/components/block-inspector/index.js +++ b/packages/block-editor/src/components/block-inspector/index.js @@ -131,7 +131,7 @@ const BlockInspectorSingleBlock = ( {
diff --git a/packages/block-editor/src/components/inspector-controls/block-support-slot-container.js b/packages/block-editor/src/components/inspector-controls/block-support-slot-container.js new file mode 100644 index 0000000000000..e450ab8b0d407 --- /dev/null +++ b/packages/block-editor/src/components/inspector-controls/block-support-slot-container.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { __experimentalToolsPanelContext as ToolsPanelContext } from '@wordpress/components'; +import { useContext } from '@wordpress/element'; + +export default function BlockSupportSlotContainer( { Slot, ...props } ) { + const toolsPanelContext = useContext( ToolsPanelContext ); + return ; +} diff --git a/packages/block-editor/src/components/inspector-controls/block-support-tools-panel.js b/packages/block-editor/src/components/inspector-controls/block-support-tools-panel.js index 33faa87f7fec1..170ecab88b624 100644 --- a/packages/block-editor/src/components/inspector-controls/block-support-tools-panel.js +++ b/packages/block-editor/src/components/inspector-controls/block-support-tools-panel.js @@ -10,7 +10,7 @@ import { useDispatch, useSelect } from '@wordpress/data'; import { store as blockEditorStore } from '../../store'; import { cleanEmptyObject } from '../../hooks/utils'; -export default function BlockSupportToolsPanel( { children, label, header } ) { +export default function BlockSupportToolsPanel( { children, label } ) { const { clientId, attributes } = useSelect( ( select ) => { const { getBlockAttributes, getSelectedBlockClientId } = select( blockEditorStore @@ -47,10 +47,11 @@ export default function BlockSupportToolsPanel( { children, label, header } ) { return ( { children } diff --git a/packages/block-editor/src/components/inspector-controls/fill.js b/packages/block-editor/src/components/inspector-controls/fill.js index bb1c8fd7accdf..af3478252de6d 100644 --- a/packages/block-editor/src/components/inspector-controls/fill.js +++ b/packages/block-editor/src/components/inspector-controls/fill.js @@ -1,7 +1,15 @@ +/** + * External dependencies + */ +import { isEmpty } from 'lodash'; + /** * WordPress dependencies */ -import { __experimentalStyleProvider as StyleProvider } from '@wordpress/components'; +import { + __experimentalStyleProvider as StyleProvider, + __experimentalToolsPanelContext as ToolsPanelContext, +} from '@wordpress/components'; import warning from '@wordpress/warning'; /** @@ -26,7 +34,20 @@ export default function InspectorControlsFill( { return ( - { children } + + { ( fillProps ) => { + // Children passed to InspectorControlsFill will not have + // access to any React Context whose Provider is part of + // the InspectorControlsSlot tree. So we re-create the + // Provider in this subtree. + const value = ! isEmpty( fillProps ) ? fillProps : null; + return ( + + { children } + + ); + } } + ); } diff --git a/packages/block-editor/src/components/inspector-controls/slot.js b/packages/block-editor/src/components/inspector-controls/slot.js index d9dfba0af06b9..c9da21817103d 100644 --- a/packages/block-editor/src/components/inspector-controls/slot.js +++ b/packages/block-editor/src/components/inspector-controls/slot.js @@ -8,6 +8,7 @@ import warning from '@wordpress/warning'; * Internal dependencies */ import BlockSupportToolsPanel from './block-support-tools-panel'; +import BlockSupportSlotContainer from './block-support-slot-container'; import groups from './groups'; export default function InspectorControlsSlot( { @@ -31,7 +32,11 @@ export default function InspectorControlsSlot( { if ( label ) { return ( - + ); } diff --git a/packages/components/src/tools-panel/context.ts b/packages/components/src/tools-panel/context.ts index 5627b458efa25..d0104db26e6b7 100644 --- a/packages/components/src/tools-panel/context.ts +++ b/packages/components/src/tools-panel/context.ts @@ -14,6 +14,7 @@ export const ToolsPanelContext = createContext< ToolsPanelContextType >( { menuItems: { default: {}, optional: {} }, hasMenuItems: false, isResetting: false, + shouldRenderPlaceholderItems: false, registerPanelItem: noop, deregisterPanelItem: noop, flagItemCustomization: noop, diff --git a/packages/components/src/tools-panel/styles.ts b/packages/components/src/tools-panel/styles.ts index 102efdcf99b7b..220db2c5ef583 100644 --- a/packages/components/src/tools-panel/styles.ts +++ b/packages/components/src/tools-panel/styles.ts @@ -9,14 +9,47 @@ import { css } from '@emotion/react'; import { COLORS, CONFIG } from '../utils'; import { space } from '../ui/utils/space'; +const toolsPanelGrid = { + container: css` + column-gap: ${ space( 4 ) }; + display: grid; + grid-template-columns: 1fr 1fr; + row-gap: ${ space( 6 ) }; + `, + item: { + halfWidth: css` + grid-column: span 1; + `, + fullWidth: css` + grid-column: span 2; + `, + }, +}; + export const ToolsPanel = css` + ${ toolsPanelGrid.container }; + border-top: ${ CONFIG.borderWidth } solid ${ COLORS.gray[ 200 ] }; - column-gap: ${ space( 4 ) }; - display: grid; - grid-template-columns: 1fr 1fr; margin-top: -1px; padding: ${ space( 4 ) }; - row-gap: ${ space( 6 ) }; +`; + +/** + * Items injected into a ToolsPanel via a virtual bubbling slot will require + * an inner dom element to be injected. The following rule allows for the + * CSS grid display to be re-established. + */ +export const ToolsPanelWithInnerWrapper = css` + > div { + ${ toolsPanelGrid.container } + ${ toolsPanelGrid.item.fullWidth } + } +`; + +export const ToolsPanelHiddenInnerWrapper = css` + > div { + display: none; + } `; export const ToolsPanelHeader = css` @@ -24,7 +57,7 @@ export const ToolsPanelHeader = css` display: flex; font-size: inherit; font-weight: 500; - grid-column: span 2; + ${ toolsPanelGrid.item.fullWidth } justify-content: space-between; line-height: normal; @@ -47,10 +80,10 @@ export const ToolsPanelHeader = css` `; export const ToolsPanelItem = css` - grid-column: span 2; + ${ toolsPanelGrid.item.fullWidth } &.single-column { - grid-column: span 1; + ${ toolsPanelGrid.item.halfWidth } } /* Clear spacing in and around controls added as panel items. */ @@ -61,6 +94,18 @@ export const ToolsPanelItem = css` margin-bottom: 0; max-width: 100%; } + + & > .components-base-control:last-child { + margin-bottom: 0; + + .components-base-control__field { + margin-bottom: 0; + } + } +`; + +export const ToolsPanelItemPlaceholder = css` + display: none; `; export const DropdownMenu = css` diff --git a/packages/components/src/tools-panel/test/index.js b/packages/components/src/tools-panel/test/index.js index 1364ab347fd8f..b8aaba1914972 100644 --- a/packages/components/src/tools-panel/test/index.js +++ b/packages/components/src/tools-panel/test/index.js @@ -7,7 +7,9 @@ import { render, screen, fireEvent } from '@testing-library/react'; * Internal dependencies */ import { ToolsPanel, ToolsPanelItem } from '../'; +import { createSlotFill, Provider as SlotFillProvider } from '../../slot-fill'; +const { Fill: ToolsPanelItems, Slot } = createSlotFill( 'ToolsPanelSlot' ); const resetAll = jest.fn(); // Default props for the tools panel. @@ -151,6 +153,10 @@ const selectMenuItem = async ( label ) => { }; describe( 'ToolsPanel', () => { + afterEach( () => { + controlProps.attributes.value = true; + } ); + describe( 'basic rendering', () => { it( 'should render panel', () => { const { container } = renderPanel(); @@ -310,12 +316,35 @@ describe( 'ToolsPanel', () => { // Groups should be: default controls, optional controls & reset all. expect( menuGroups.length ).toEqual( 3 ); } ); + + it( 'should render placeholder items when panel opts into that feature', () => { + const { container } = render( + + +
Optional control
+
+
+ ); + + const optionalItem = screen.queryByText( 'Optional control' ); + const placeholder = container.querySelector( + '.components-tools-panel-item' + ); + + // When rendered as a placeholder a ToolsPanelItem will just omit + // all the item's children. So we should still find the container + // element but not the text etc within. + expect( optionalItem ).not.toBeInTheDocument(); + expect( placeholder ).toBeInTheDocument(); + } ); } ); describe( 'callbacks on menu item selection', () => { beforeEach( () => { jest.clearAllMocks(); - controlProps.attributes.value = true; } ); it( 'should call onDeselect callback when menu item is toggled off', async () => { @@ -425,4 +454,59 @@ describe( 'ToolsPanel', () => { expect( altMenuItem ).toHaveAttribute( 'aria-checked', 'false' ); } ); } ); + + describe( 'rendering via SlotFills', () => { + it( 'should maintain visual order of controls when toggled on and off', async () => { + // Multiple fills are added to better simulate panel items being + // injected from different locations. + render( + + + +
Item 1
+
+
+ + +
Item 2
+
+
+ + + +
+ ); + + // Only the second item should be shown initially as it has a value. + const firstItem = screen.queryByText( 'Item 1' ); + const secondItem = screen.getByText( 'Item 2' ); + + expect( firstItem ).not.toBeInTheDocument(); + expect( secondItem ).toBeInTheDocument(); + + // Toggle on the first item. + await selectMenuItem( altControlProps.label ); + + // The order of items should be as per their original source order. + let items = screen.getAllByText( /Item [1-2]/ ); + + expect( items ).toHaveLength( 2 ); + expect( items[ 0 ] ).toHaveTextContent( 'Item 1' ); + expect( items[ 1 ] ).toHaveTextContent( 'Item 2' ); + + // Then toggle off both items. + await selectMenuItem( controlProps.label ); + await selectMenuItem( altControlProps.label ); + + // Toggle on controls again and ensure order remains. + await selectMenuItem( controlProps.label ); + await selectMenuItem( altControlProps.label ); + + items = screen.getAllByText( /Item [1-2]/ ); + + expect( items ).toHaveLength( 2 ); + expect( items[ 0 ] ).toHaveTextContent( 'Item 1' ); + expect( items[ 1 ] ).toHaveTextContent( 'Item 2' ); + } ); + } ); } ); diff --git a/packages/components/src/tools-panel/tools-panel-item/component.tsx b/packages/components/src/tools-panel/tools-panel-item/component.tsx index cde52e5f3ec0f..a0a1791295e97 100644 --- a/packages/components/src/tools-panel/tools-panel-item/component.tsx +++ b/packages/components/src/tools-panel/tools-panel-item/component.tsx @@ -18,12 +18,17 @@ const ToolsPanelItem = ( props: WordPressComponentProps< ToolsPanelItemProps, 'div' >, forwardedRef: Ref< any > ) => { - const { children, isShown, ...toolsPanelItemProps } = useToolsPanelItem( - props - ); + const { + children, + isShown, + shouldRenderPlaceholder, + ...toolsPanelItemProps + } = useToolsPanelItem( props ); if ( ! isShown ) { - return null; + return shouldRenderPlaceholder ? ( + + ) : null; } return ( diff --git a/packages/components/src/tools-panel/tools-panel-item/hook.ts b/packages/components/src/tools-panel/tools-panel-item/hook.ts index 3b3762cd82782..a68692b40531d 100644 --- a/packages/components/src/tools-panel/tools-panel-item/hook.ts +++ b/packages/components/src/tools-panel/tools-panel-item/hook.ts @@ -28,11 +28,6 @@ export function useToolsPanelItem( ...otherProps } = useContextSystem( props, 'ToolsPanelItem' ); - const cx = useCx(); - const classes = useMemo( () => { - return cx( styles.ToolsPanelItem, className ); - }, [ className ] ); - const { panelId: currentPanelId, menuItems, @@ -40,6 +35,7 @@ export function useToolsPanelItem( deregisterPanelItem, flagItemCustomization, isResetting, + shouldRenderPlaceholderItems: shouldRenderPlaceholder, } = useToolsPanelContext(); const hasValueCallback = useCallback( hasValue, [ panelId ] ); @@ -115,9 +111,19 @@ export function useToolsPanelItem( ? menuItems?.[ menuGroup ]?.[ label ] !== undefined : isMenuItemChecked; + const cx = useCx(); + const classes = useMemo( () => { + const placeholderStyle = + shouldRenderPlaceholder && + ! isShown && + styles.ToolsPanelItemPlaceholder; + return cx( styles.ToolsPanelItem, placeholderStyle, className ); + }, [ isShown, shouldRenderPlaceholder, className ] ); + return { ...otherProps, isShown, + shouldRenderPlaceholder, className: classes, }; } diff --git a/packages/components/src/tools-panel/tools-panel/README.md b/packages/components/src/tools-panel/tools-panel/README.md index de56ab3bdd5c9..f01f2b329fac5 100644 --- a/packages/components/src/tools-panel/tools-panel/README.md +++ b/packages/components/src/tools-panel/tools-panel/README.md @@ -67,6 +67,13 @@ export function DimensionPanel( props ) { ## Props +### `hasInnerWrapper`: `boolean` + +Flags that the items in this ToolsPanel will be contained within an inner +wrapper element allowing the panel to lay them out accordingly. + +- Required: No + ### `label`: `string` Text to be displayed within the panel's header and as the `aria-label` for the @@ -88,3 +95,10 @@ A function to call when the `Reset all` menu option is selected. This is passed through to the panel's header component. - Required: Yes + +### `shouldRenderPlaceholderItems`: `boolean` + +Advises the `ToolsPanel` that all of its `ToolsPanelItem` children should render +placeholder content (instead of `null`) when they are toggled off and hidden. + +- Required: No diff --git a/packages/components/src/tools-panel/tools-panel/hook.ts b/packages/components/src/tools-panel/tools-panel/hook.ts index 9aacf97ed26fa..afbf3174fae90 100644 --- a/packages/components/src/tools-panel/tools-panel/hook.ts +++ b/packages/components/src/tools-panel/tools-panel/hook.ts @@ -34,15 +34,14 @@ const generateMenuItems = ( { export function useToolsPanel( props: WordPressComponentProps< ToolsPanelProps, 'div' > ) { - const { className, resetAll, panelId, ...otherProps } = useContextSystem( - props, - 'ToolsPanel' - ); - - const cx = useCx(); - const classes = useMemo( () => { - return cx( styles.ToolsPanel, className ); - }, [ className ] ); + const { + className, + resetAll, + panelId, + hasInnerWrapper, + shouldRenderPlaceholderItems, + ...otherProps + } = useContextSystem( props, 'ToolsPanel' ); const isResetting = useRef( false ); const wasResetting = isResetting.current; @@ -110,6 +109,46 @@ export function useToolsPanel( } ); }; + // Track whether all optional controls are displayed or not. + // If no optional controls are present, then none are hidden and this will + // be `false`. + const [ + areAllOptionalControlsHidden, + setAreAllOptionalControlsHidden, + ] = useState( false ); + + // We need to track whether any optional menu items are active to later + // determine whether the panel is currently empty and any inner wrapper + // should be hidden. + useEffect( () => { + if ( menuItems.optional ) { + const optionalItems = Object.entries( menuItems.optional ); + const allControlsHidden = + optionalItems.length > 0 && + ! optionalItems.some( ( [ , isSelected ] ) => isSelected ); + setAreAllOptionalControlsHidden( allControlsHidden ); + } + }, [ menuItems.optional ] ); + + const cx = useCx(); + const classes = useMemo( () => { + const hasDefaultMenuItems = + menuItems?.default && !! Object.keys( menuItems?.default ).length; + const wrapperStyle = + hasInnerWrapper && styles.ToolsPanelWithInnerWrapper; + const emptyStyle = + ! hasDefaultMenuItems && + areAllOptionalControlsHidden && + styles.ToolsPanelHiddenInnerWrapper; + + return cx( styles.ToolsPanel, wrapperStyle, emptyStyle, className ); + }, [ + className, + hasInnerWrapper, + menuItems, + areAllOptionalControlsHidden, + ] ); + // Toggle the checked state of a menu item which is then used to determine // display of the item within the panel. const toggleItem = ( label: string ) => { @@ -166,6 +205,7 @@ export function useToolsPanel( flagItemCustomization, hasMenuItems: !! panelItems.length, isResetting: isResetting.current, + shouldRenderPlaceholderItems, }; return { diff --git a/packages/components/src/tools-panel/types.ts b/packages/components/src/tools-panel/types.ts index f763fe13e1ce8..1617f32f313bc 100644 --- a/packages/components/src/tools-panel/types.ts +++ b/packages/components/src/tools-panel/types.ts @@ -12,6 +12,11 @@ export type ToolsPanelProps = { * The child elements. */ children: ReactNode; + /** + * Flags that the items in this ToolsPanel will be contained within an inner + * wrapper element allowing the panel to lay them out accordingly. + */ + hasInnerWrapper: boolean; /** * Text to be displayed within the panel's header and as the `aria-label` * for the panel's dropdown menu. @@ -28,6 +33,11 @@ export type ToolsPanelProps = { * passed through to the panel's header component. */ resetAll: ResetAll; + /** + * Advises the `ToolsPanel` that its child `ToolsPanelItem`s should render + * placeholder content instead of null when they are toggled off and hidden. + */ + shouldRenderPlaceholderItems: boolean; }; export type ToolsPanelHeaderProps = { @@ -116,6 +126,7 @@ export type ToolsPanelContext = { deregisterPanelItem: ( label: string ) => void; flagItemCustomization: ( label: string ) => void; isResetting: boolean; + shouldRenderPlaceholderItems: boolean; }; export type ToolsPanelControlsGroupProps = {