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 450a8351de8e3..94344c88bc01a 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 57189eea3916a..3553df9aac0b2 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 ] );
@@ -108,9 +104,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 = {