diff --git a/packages/components/src/tools-panel/test/index.tsx b/packages/components/src/tools-panel/test/index.tsx
index 68cf72233121e0..8b320d1752e814 100644
--- a/packages/components/src/tools-panel/test/index.tsx
+++ b/packages/components/src/tools-panel/test/index.tsx
@@ -390,6 +390,32 @@ describe( 'ToolsPanel', () => {
expect( controlRerendered ).toBeInTheDocument();
} );
+ it( 'should render optional item on first render when isShownOnFirstRender is true', () => {
+ const { rerender } = render(
+
+
+ Optional control
+
+
+ );
+
+ expect(
+ screen.queryByText( 'Optional control' )
+ ).not.toBeInTheDocument();
+
+ rerender(
+
+
+ Optional control
+
+
+ );
+
+ expect(
+ screen.getByText( 'Optional control' )
+ ).toBeInTheDocument();
+ } );
+
it( 'should continue to render shown by default item after it is toggled off via menu item', async () => {
render(
@@ -749,6 +775,23 @@ describe( 'ToolsPanel', () => {
expect( altControlProps.onDeselect ).not.toHaveBeenCalled();
} );
+ it( 'should call onDeselect when optional item with no value is hidden', async () => {
+ // Covers the case where an optional item is shown via the menu but
+ // never receives a value. Previously, onDeselect would silently not
+ // fire because it was gated on hasValue() being true.
+ renderPanel();
+
+ await openDropdownMenu();
+ await selectMenuItem( altControlProps.label );
+
+ expect( altControlProps.onSelect ).toHaveBeenCalledTimes( 1 );
+ expect( altControlProps.onDeselect ).not.toHaveBeenCalled();
+
+ await selectMenuItem( altControlProps.label );
+
+ expect( altControlProps.onDeselect ).toHaveBeenCalledTimes( 1 );
+ } );
+
it( 'should call resetAll callback when its menu item is selected', async () => {
renderPanel();
diff --git a/packages/components/src/tools-panel/tools-panel-item/README.md b/packages/components/src/tools-panel/tools-panel-item/README.md
index 91f9c78ff9cbe8..67af180b8102d7 100644
--- a/packages/components/src/tools-panel/tools-panel-item/README.md
+++ b/packages/components/src/tools-panel/tools-panel-item/README.md
@@ -34,6 +34,13 @@ panel's menu.
- Required: No
- Default: `false`
+### `isShownOnFirstRender`: `boolean`
+
+For optional controls only, this determines whether the item should be visible
+when it first renders, even if `hasValue()` is `false`.
+
+- Required: No
+
### `label`: `string`
The supplied label is dual purpose.
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 e0a1f0139dcace..53960200507616 100644
--- a/packages/components/src/tools-panel/tools-panel-item/hook.ts
+++ b/packages/components/src/tools-panel/tools-panel-item/hook.ts
@@ -7,6 +7,7 @@ import {
useEffect,
useLayoutEffect,
useMemo,
+ useRef,
} from '@wordpress/element';
/**
@@ -28,6 +29,7 @@ export function useToolsPanelItem(
className,
hasValue,
isShownByDefault = false,
+ isShownOnFirstRender,
label,
panelId,
resetAllFilter = noop,
@@ -74,6 +76,7 @@ export function useToolsPanelItem(
registerPanelItem( {
hasValue: hasValueCallback,
isShownByDefault,
+ isShownOnFirstRender,
label,
panelId,
} );
@@ -91,6 +94,7 @@ export function useToolsPanelItem(
currentPanelId,
hasMatchingPanel,
isShownByDefault,
+ isShownOnFirstRender,
label,
hasValueCallback,
panelId,
@@ -139,6 +143,11 @@ export function useToolsPanelItem(
isShownByDefault,
] );
+ // Tracks whether the item was explicitly shown by a user menu action.
+ // This allows onDeselect to fire even when hasValue() is false, but only
+ // for items the user intentionally toggled on.
+ const wasShownByUserRef = useRef( false );
+
// Determine if the panel item's corresponding menu is being toggled and
// trigger appropriate callback if it is.
useEffect( () => {
@@ -150,11 +159,15 @@ export function useToolsPanelItem(
}
if ( isMenuItemChecked && ! isValueSet && ! wasMenuItemChecked ) {
+ wasShownByUserRef.current = true;
onSelect?.();
}
- if ( ! isMenuItemChecked && isValueSet && wasMenuItemChecked ) {
- onDeselect?.();
+ if ( ! isMenuItemChecked && wasMenuItemChecked ) {
+ if ( isValueSet || wasShownByUserRef.current ) {
+ onDeselect?.();
+ }
+ wasShownByUserRef.current = false;
}
}, [
hasMatchingPanel,
diff --git a/packages/components/src/tools-panel/tools-panel/hook.ts b/packages/components/src/tools-panel/tools-panel/hook.ts
index 583a079ab20026..b522c5fd9c2a71 100644
--- a/packages/components/src/tools-panel/tools-panel/hook.ts
+++ b/packages/components/src/tools-panel/tools-panel/hook.ts
@@ -62,17 +62,23 @@ const generateMenuItems = ( {
const newMenuItems: ToolsPanelMenuItems = emptyMenuItems();
const menuItems: ToolsPanelMenuItems = emptyMenuItems();
- panelItems.forEach( ( { hasValue, isShownByDefault, label } ) => {
- const group = isShownByDefault ? 'default' : 'optional';
-
- // If a menu item for this label has already been flagged as customized
- // (for default controls), or toggled on (for optional controls), do not
- // overwrite its value as those controls would lose that state.
- const existingItemValue = currentMenuItems?.[ group ]?.[ label ];
- const value = existingItemValue ? existingItemValue : hasValue();
-
- newMenuItems[ group ][ label ] = shouldReset ? false : value;
- } );
+ panelItems.forEach(
+ ( { hasValue, isShownByDefault, isShownOnFirstRender, label } ) => {
+ const group = isShownByDefault ? 'default' : 'optional';
+
+ // If a menu item for this label has already been flagged as customized
+ // (for default controls), or toggled on (for optional controls), do not
+ // overwrite its value as those controls would lose that state.
+ const existingItemValue = currentMenuItems?.[ group ]?.[ label ];
+ const initialValue =
+ ! isShownByDefault && isShownOnFirstRender !== undefined
+ ? isShownOnFirstRender
+ : hasValue();
+ const value = existingItemValue ?? initialValue;
+
+ newMenuItems[ group ][ label ] = shouldReset ? false : value;
+ }
+ );
// Loop the known, previously registered items first to maintain menu order.
menuItemOrder.forEach( ( key ) => {
diff --git a/packages/components/src/tools-panel/types.ts b/packages/components/src/tools-panel/types.ts
index ac1fca74a465ea..57b9b3a301a9fc 100644
--- a/packages/components/src/tools-panel/types.ts
+++ b/packages/components/src/tools-panel/types.ts
@@ -123,6 +123,11 @@ export type ToolsPanelItem = {
* @default false
*/
isShownByDefault?: boolean;
+ /**
+ * For optional items only, determines whether the item should be visible on
+ * first render even when `hasValue()` is false.
+ */
+ isShownOnFirstRender?: boolean;
/**
* The supplied label is dual purpose. It is used as:
* 1. the human-readable label for the panel's dropdown menu