fix: prevent asset conflicts between React and Grid.js versions

Add coexistence checks to all enqueue methods to prevent loading
both React and Grid.js assets simultaneously.

Changes:
- ReactAdmin.php: Only enqueue React assets when ?react=1
- Init.php: Skip Grid.js when React active on admin pages
- Form.php, Coupon.php, Access.php: Restore classic assets when ?react=0
- Customer.php, Product.php, License.php: Add coexistence checks

Now the toggle between Classic and React versions works correctly.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
dwindown
2026-04-18 17:02:14 +07:00
parent bd9cdac02e
commit e8fbfb14c1
74973 changed files with 6658406 additions and 71 deletions

View File

@@ -0,0 +1,27 @@
/**
* WordPress dependencies
*/
import { createContext, useContext } from '@wordpress/element';
/**
* Internal dependencies
*/
import type { ToolsPanelContext as ToolsPanelContextType } from './types';
const noop = () => undefined;
export const ToolsPanelContext = createContext< ToolsPanelContextType >( {
menuItems: { default: {}, optional: {} },
hasMenuItems: false,
isResetting: false,
shouldRenderPlaceholderItems: false,
registerPanelItem: noop,
deregisterPanelItem: noop,
flagItemCustomization: noop,
registerResetAllFilter: noop,
deregisterResetAllFilter: noop,
areAllOptionalControlsHidden: true,
} );
export const useToolsPanelContext = () =>
useContext< ToolsPanelContextType >( ToolsPanelContext );

View File

@@ -0,0 +1,3 @@
export { default as ToolsPanel } from './tools-panel';
export { default as ToolsPanelItem } from './tools-panel-item';
export { ToolsPanelContext } from './context';

View File

@@ -0,0 +1,619 @@
/**
* External dependencies
*/
import type { Meta, StoryFn } from '@storybook/react';
import styled from '@emotion/styled';
/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import {
ToggleGroupControl,
ToggleGroupControlOption,
} from '../../toggle-group-control';
/**
* Internal dependencies
*/
import { ToolsPanel, ToolsPanelItem } from '..';
import Panel from '../../panel';
import UnitControl from '../../unit-control';
import { createSlotFill, Provider as SlotFillProvider } from '../../slot-fill';
const meta: Meta< typeof ToolsPanel > = {
title: 'Components (Experimental)/ToolsPanel',
component: ToolsPanel,
// @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
subcomponents: { ToolsPanelItem },
argTypes: {
as: { control: { type: null } },
children: { control: { type: null } },
panelId: { control: { type: null } },
resetAll: { action: 'resetAll' },
},
parameters: {
actions: { argTypesRegex: '^on.*' },
controls: {
expanded: true,
},
docs: { canvas: { sourceState: 'shown' } },
},
};
export default meta;
export const Default: StoryFn< typeof ToolsPanel > = ( {
resetAll: resetAllProp,
...props
} ) => {
const [ height, setHeight ] = useState< string | undefined >();
const [ minHeight, setMinHeight ] = useState< string | undefined >();
const [ width, setWidth ] = useState< string | undefined >();
const [ scale, setScale ] = useState< React.ReactText | undefined >();
const resetAll: typeof resetAllProp = ( filters ) => {
setHeight( undefined );
setWidth( undefined );
setMinHeight( undefined );
setScale( undefined );
resetAllProp( filters );
};
return (
<PanelWrapperView>
<Panel>
<ToolsPanel { ...props } resetAll={ resetAll }>
<SingleColumnItem
hasValue={ () => !! width }
label="Width"
onDeselect={ () => setWidth( undefined ) }
isShownByDefault={ true }
>
<UnitControl
label="Width"
value={ width }
onChange={ ( next ) => setWidth( next ) }
/>
</SingleColumnItem>
<SingleColumnItem
hasValue={ () => !! height }
label="Height"
onDeselect={ () => setHeight( undefined ) }
isShownByDefault={ true }
>
<UnitControl
label="Height"
value={ height }
onChange={ ( next ) => setHeight( next ) }
/>
</SingleColumnItem>
<ToolsPanelItem
hasValue={ () => !! minHeight }
label="Minimum height"
onDeselect={ () => setMinHeight( undefined ) }
isShownByDefault={ true }
>
<UnitControl
label="Minimum height"
value={ minHeight }
onChange={ ( next ) => setMinHeight( next ) }
/>
</ToolsPanelItem>
<ToolsPanelItem
hasValue={ () => !! scale }
label="Scale"
onDeselect={ () => setScale( undefined ) }
>
<ToggleGroupControl
__nextHasNoMarginBottom
label="Scale"
value={ scale }
onChange={ ( next ) => setScale( next ) }
isBlock
>
<ToggleGroupControlOption
value="cover"
label="Cover"
/>
<ToggleGroupControlOption
value="contain"
label="Contain"
/>
<ToggleGroupControlOption
value="fill"
label="Fill"
/>
</ToggleGroupControl>
</ToolsPanelItem>
</ToolsPanel>
</Panel>
</PanelWrapperView>
);
};
Default.args = {
label: 'Tools Panel (default example)',
};
export const WithNonToolsPanelItems: StoryFn< typeof ToolsPanel > = ( {
resetAll: resetAllProp,
...props
} ) => {
const [ height, setHeight ] = useState< string | undefined >();
const [ width, setWidth ] = useState< string | undefined >();
const resetAll: typeof resetAllProp = ( filters ) => {
setHeight( undefined );
setWidth( undefined );
resetAllProp( filters );
};
return (
<PanelWrapperView>
<Panel>
<ToolsPanel { ...props } resetAll={ resetAll }>
<IntroText>
This text illustrates not all items must be wrapped in a
ToolsPanelItem and represented in the panel menu.
</IntroText>
<SingleColumnItem
hasValue={ () => !! width }
label="Width"
onDeselect={ () => setWidth( undefined ) }
isShownByDefault={ true }
>
<UnitControl
label="Width"
value={ width }
onChange={ ( next ) => setWidth( next ) }
/>
</SingleColumnItem>
<SingleColumnItem
hasValue={ () => !! height }
label="Height"
onDeselect={ () => setHeight( undefined ) }
isShownByDefault={ true }
>
<UnitControl
label="Height"
value={ height }
onChange={ ( next ) => setHeight( next ) }
/>
</SingleColumnItem>
</ToolsPanel>
</Panel>
</PanelWrapperView>
);
};
WithNonToolsPanelItems.args = {
...Default.args,
label: 'ToolsPanel (with non-menu items)',
};
export const WithOptionalItemsPlusIcon: StoryFn< typeof ToolsPanel > = ( {
resetAll: resetAllProp,
...props
} ) => {
const [
isFirstToolsPanelItemShownByDefault,
setIsFirstToolsPanelItemShownByDefault,
] = useState( false );
const [ height, setHeight ] = useState< string | undefined >();
const [ width, setWidth ] = useState< string | undefined >();
const [ minWidth, setMinWidth ] = useState< string | undefined >();
const resetAll: typeof resetAllProp = ( filters ) => {
setHeight( undefined );
setWidth( undefined );
setMinWidth( undefined );
resetAllProp( filters );
};
return (
<>
<PanelWrapperView>
<Panel>
<ToolsPanel
{ ...props }
resetAll={ resetAll }
// `key` property here is used as a hack to force `ToolsPanel` to re-render
// See https://github.com/WordPress/gutenberg/pull/38262/files#r793422991
key={
isFirstToolsPanelItemShownByDefault
? 'true'
: 'false'
}
>
<SingleColumnItem
hasValue={ () => !! minWidth }
label="Minimum width"
onDeselect={ () => setMinWidth( undefined ) }
isShownByDefault={
isFirstToolsPanelItemShownByDefault
}
>
<UnitControl
label="Minimum width"
value={ minWidth }
onChange={ ( next ) => setMinWidth( next ) }
/>
</SingleColumnItem>
<SingleColumnItem
hasValue={ () => !! width }
label="Width"
onDeselect={ () => setWidth( undefined ) }
isShownByDefault={ false }
>
<UnitControl
label="Width"
value={ width }
onChange={ ( next ) => setWidth( next ) }
/>
</SingleColumnItem>
<SingleColumnItem
hasValue={ () => !! height }
label="Height"
onDeselect={ () => setHeight( undefined ) }
isShownByDefault={ false }
>
<UnitControl
label="Height"
value={ height }
onChange={ ( next ) => setHeight( next ) }
/>
</SingleColumnItem>
</ToolsPanel>
</Panel>
</PanelWrapperView>
<button
onClick={ () =>
setIsFirstToolsPanelItemShownByDefault(
! isFirstToolsPanelItemShownByDefault
)
}
aria-pressed={
isFirstToolsPanelItemShownByDefault ? 'true' : 'false'
}
style={ {
marginTop: '2rem',
} }
>
{ isFirstToolsPanelItemShownByDefault
? 'Make first PanelItem hidden by default'
: 'Make first PanelItem shown by default' }
</button>
</>
);
};
WithOptionalItemsPlusIcon.args = {
...Default.args,
label: 'Tools Panel (optional items only)',
};
const { Fill: ToolsPanelItems, Slot } = createSlotFill( 'ToolsPanelSlot' );
export const WithSlotFillItems: StoryFn< typeof ToolsPanel > = ( {
resetAll: resetAllProp,
panelId,
...props
} ) => {
const [ attributes, setAttributes ] = useState< {
width?: string;
height?: string;
} >( {} );
const { width, height } = attributes;
const resetAll: typeof resetAllProp = ( resetFilters = [] ) => {
let newAttributes: typeof attributes = {};
resetFilters.forEach( ( resetFilter ) => {
newAttributes = {
...newAttributes,
...resetFilter( newAttributes ),
};
} );
setAttributes( newAttributes );
resetAllProp( resetFilters );
};
const updateAttribute = ( name: string, value?: any ) => {
setAttributes( {
...attributes,
[ name ]: value,
} );
};
return (
<SlotFillProvider>
<ToolsPanelItems>
<SingleColumnItem
hasValue={ () => !! width }
label="Injected Width"
onDeselect={ () => updateAttribute( 'width', undefined ) }
resetAllFilter={ () => ( { width: undefined } ) }
panelId={ panelId }
>
<UnitControl
label="Injected Width"
value={ width }
onChange={ ( next ) =>
updateAttribute( 'width', next )
}
/>
</SingleColumnItem>
<SingleColumnItem
hasValue={ () => !! height }
label="Injected Height"
onDeselect={ () => updateAttribute( 'height', undefined ) }
resetAllFilter={ () => ( { height: undefined } ) }
panelId={ panelId }
>
<UnitControl
label="Injected Height"
value={ height }
onChange={ ( next ) =>
updateAttribute( 'height', next )
}
/>
</SingleColumnItem>
<ToolsPanelItem
hasValue={ () => true }
label="Item for alternate panel"
onDeselect={ () => undefined }
resetAllFilter={ () => undefined }
panelId={ 'intended-for-another-panel-via-shared-slot' }
>
<p>
This panel item will not be displayed in the demo as its
panelId does not match the panel being rendered.
</p>
</ToolsPanelItem>
</ToolsPanelItems>
<PanelWrapperView>
<Panel>
<ToolsPanel
{ ...props }
resetAll={ resetAll }
panelId={ panelId }
>
<Slot />
</ToolsPanel>
</Panel>
</PanelWrapperView>
</SlotFillProvider>
);
};
WithSlotFillItems.args = {
...Default.args,
label: 'Tools Panel With SlotFill Items',
panelId: 'unique-tools-panel-id',
};
export const WithConditionalDefaultControl: StoryFn< typeof ToolsPanel > = ( {
resetAll: resetAllProp,
panelId,
...props
} ) => {
const [ attributes, setAttributes ] = useState< {
height?: string;
scale?: React.ReactText;
} >( {} );
const { height, scale } = attributes;
const resetAll: typeof resetAllProp = ( resetFilters = [] ) => {
let newAttributes: typeof attributes = {};
resetFilters.forEach( ( resetFilter ) => {
newAttributes = {
...newAttributes,
...resetFilter( newAttributes ),
};
} );
setAttributes( newAttributes );
resetAllProp( resetFilters );
};
const updateAttribute = ( name: string, value?: any ) => {
setAttributes( {
...attributes,
[ name ]: value,
} );
};
return (
<SlotFillProvider>
<ToolsPanelItems>
<SingleColumnItem
hasValue={ () => !! height }
label="Injected Height"
onDeselect={ () => updateAttribute( 'height', undefined ) }
resetAllFilter={ () => ( { height: undefined } ) }
panelId={ panelId }
isShownByDefault={ true }
>
<UnitControl
label="Injected Height"
value={ height }
onChange={ ( next ) =>
updateAttribute( 'height', next )
}
/>
</SingleColumnItem>
<ToolsPanelItem
hasValue={ () => !! scale }
label="Scale"
onDeselect={ () => updateAttribute( 'scale', undefined ) }
resetAllFilter={ () => ( { scale: undefined } ) }
panelId={ panelId }
isShownByDefault={ !! height }
>
<ToggleGroupControl
__nextHasNoMarginBottom
label="Scale"
value={ scale }
onChange={ ( next ) =>
updateAttribute( 'scale', next )
}
isBlock
>
<ToggleGroupControlOption value="cover" label="Cover" />
<ToggleGroupControlOption
value="contain"
label="Contain"
/>
<ToggleGroupControlOption value="fill" label="Fill" />
</ToggleGroupControl>
</ToolsPanelItem>
</ToolsPanelItems>
<PanelWrapperView>
<Panel>
<ToolsPanel
{ ...props }
resetAll={ resetAll }
panelId={ panelId }
>
<Slot />
</ToolsPanel>
</Panel>
</PanelWrapperView>
</SlotFillProvider>
);
};
WithConditionalDefaultControl.args = {
...Default.args,
label: 'Tools Panel With Conditional Default via SlotFill',
panelId: 'unique-tools-panel-id',
};
export const WithConditionallyRenderedControl: StoryFn<
typeof ToolsPanel
> = ( { resetAll: resetAllProp, panelId, ...props } ) => {
const [ attributes, setAttributes ] = useState< {
height?: string;
scale?: React.ReactText;
} >( {} );
const { height, scale } = attributes;
const resetAll: typeof resetAllProp = ( resetFilters = [] ) => {
let newAttributes: typeof attributes = {};
resetFilters.forEach( ( resetFilter ) => {
newAttributes = {
...newAttributes,
...resetFilter( newAttributes ),
};
} );
setAttributes( newAttributes );
resetAllProp( resetFilters );
};
const updateAttribute = ( name: string, value?: any ) => {
setAttributes( {
...attributes,
[ name ]: value,
} );
};
return (
<SlotFillProvider>
<ToolsPanelItems>
<SingleColumnItem
hasValue={ () => !! height }
label="Injected Height"
onDeselect={ () => {
updateAttribute( 'scale', undefined );
updateAttribute( 'height', undefined );
} }
resetAllFilter={ () => ( { height: undefined } ) }
panelId={ panelId }
isShownByDefault={ true }
>
<UnitControl
label="Injected Height"
value={ height }
onChange={ ( next ) =>
updateAttribute( 'height', next )
}
/>
</SingleColumnItem>
{ !! height && (
<ToolsPanelItem
hasValue={ () => !! scale }
label="Scale"
onDeselect={ () =>
updateAttribute( 'scale', undefined )
}
resetAllFilter={ () => ( { scale: undefined } ) }
panelId={ panelId }
isShownByDefault={ true }
>
<ToggleGroupControl
__nextHasNoMarginBottom
label="Scale"
value={ scale }
onChange={ ( next ) =>
updateAttribute( 'scale', next )
}
isBlock
>
<ToggleGroupControlOption
value="cover"
label="Cover"
/>
<ToggleGroupControlOption
value="contain"
label="Contain"
/>
<ToggleGroupControlOption
value="fill"
label="Fill"
/>
</ToggleGroupControl>
</ToolsPanelItem>
) }
</ToolsPanelItems>
<PanelWrapperView>
<Panel>
<ToolsPanel
{ ...props }
resetAll={ resetAll }
panelId={ panelId }
>
<Slot />
</ToolsPanel>
</Panel>
</PanelWrapperView>
</SlotFillProvider>
);
};
WithConditionallyRenderedControl.args = {
...Default.args,
label: 'Tools Panel With Conditionally Rendered Item via SlotFill',
panelId: 'unique-tools-panel-id',
};
const PanelWrapperView = styled.div`
font-size: 13px;
.components-dropdown-menu__menu {
max-width: 220px;
}
`;
const SingleColumnItem = styled( ToolsPanelItem )`
grid-column: span 1;
`;
const IntroText = styled.div`
grid-column: span 2;
`;

View File

@@ -0,0 +1,174 @@
/**
* External dependencies
*/
import styled from '@emotion/styled';
import { css } from '@emotion/react';
/**
* Internal dependencies
*/
import {
StyledField as BaseControlField,
StyledHelp as BaseControlHelp,
Wrapper as BaseControlWrapper,
} from '../base-control/styles/base-control-styles';
import { LabelWrapper } from '../input-control/styles/input-control-styles';
import { COLORS, CONFIG, rtl } from '../utils';
import { space } from '../utils/space';
const toolsPanelGrid = {
columns: ( columns: number ) => css`
grid-template-columns: ${ `repeat( ${ columns }, minmax(0, 1fr) )` };
`,
spacing: css`
column-gap: ${ space( 2 ) };
row-gap: ${ space( 4 ) };
`,
item: {
fullWidth: css`
grid-column: 1 / -1;
`,
},
};
export const ToolsPanel = ( columns: number ) => css`
${ toolsPanelGrid.columns( columns ) }
${ toolsPanelGrid.spacing }
border-top: ${ CONFIG.borderWidth } solid ${ COLORS.gray[ 300 ] };
margin-top: -1px;
padding: ${ space( 4 ) };
`;
/**
* 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 = ( columns: number ) => {
return css`
> div:not( :first-of-type ) {
display: grid;
${ toolsPanelGrid.columns( columns ) }
${ toolsPanelGrid.spacing }
${ toolsPanelGrid.item.fullWidth }
}
`;
};
export const ToolsPanelHiddenInnerWrapper = css`
> div:not( :first-of-type ) {
display: none;
}
`;
export const ToolsPanelHeader = css`
${ toolsPanelGrid.item.fullWidth }
gap: ${ space( 2 ) };
/**
* The targeting of dropdown menu component classes here is a temporary
* measure only.
*
* The following styles should be replaced once the DropdownMenu has been
* refactored and can be targeted via component interpolation.
*/
.components-dropdown-menu {
margin: ${ space( -1 ) } 0;
line-height: 0;
}
&&&& .components-dropdown-menu__toggle {
padding: 0;
min-width: ${ space( 6 ) };
}
`;
export const ToolsPanelHeading = css`
font-size: inherit;
font-weight: 500;
line-height: normal;
/* Required to meet specificity requirements to ensure zero margin */
&& {
margin: 0;
}
`;
export const ToolsPanelItem = css`
${ toolsPanelGrid.item.fullWidth }
/* Clear spacing in and around controls added as panel items. */
/* Remove when they can be addressed via context system. */
& > div,
& > fieldset {
padding-bottom: 0;
margin-bottom: 0;
max-width: 100%;
}
/* Remove BaseControl components margins and leave spacing to grid layout */
&& ${ BaseControlWrapper } {
margin-bottom: 0;
/**
* To maintain proper spacing within a base control, the field's bottom
* margin should only be removed when there is no help text included and
* it is therefore the last-child.
*/
${ BaseControlField }:last-child {
margin-bottom: 0;
}
}
${ BaseControlHelp } {
margin-bottom: 0;
}
/**
* Standardize InputControl and BaseControl labels with other labels when
* inside ToolsPanel.
*
* This is a temporary fix until the different control components have their
* labels normalized.
*/
&& ${ LabelWrapper } {
label {
line-height: 1.4em;
}
}
`;
export const ToolsPanelItemPlaceholder = css`
display: none;
`;
export const DropdownMenu = css`
min-width: 200px;
`;
export const ResetLabel = styled.span`
color: ${ COLORS.theme.accentDarker10 };
font-size: 11px;
font-weight: 500;
line-height: 1.4;
${ rtl( { marginLeft: space( 3 ) } ) }
text-transform: uppercase;
`;
export const DefaultControlsItem = css`
color: ${ COLORS.gray[ 900 ] };
&&[aria-disabled='true'] {
color: ${ COLORS.gray[ 700 ] };
opacity: 1;
&:hover {
color: ${ COLORS.gray[ 700 ] };
}
${ ResetLabel } {
opacity: 0.3;
}
}
`;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
# ToolsPanelHeader
<div class="callout callout-alert">
This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes.
</div>
<br />
This component renders a tools panel's header including a menu.
## Usage
This component is generated automatically by its parent
`ToolsPanel`.
<div class="callout callout-alert">
<strong>In general, this should not be used directly.</strong>
</div>
## Props
### `dropdownMenuProps`: `{}`
The dropdown menu props to configure the panel's `DropdownMenu`.
- Type: `DropdownMenuProps`
- Required: No
### `headingLevel`: `1 | 2 | 3 | 4 | 5 | 6 | '1' | '2' | '3' | '4' | '5' | '6'`
The heading level of the panel's header.
- Required: No
- Default: `2`
### `label`: `string`
Text to be displayed within the panel header. It is also passed along as the
`label` for the panel header's `DropdownMenu`.
- Required: Yes
### `resetAll`: `() => void`
The `resetAll` prop provides the callback to execute when the "Reset all" menu
item is selected. Its purpose is to facilitate resetting any control values
for items contained within this header's panel.
- Required: Yes
### `toggleItem`: `( label: string ) => void`
This is executed when an individual control's menu item is toggled. It
will update the panel's menu item state and call the panel item's `onSelect` or
`onDeselect` callbacks as appropriate.
- Required: Yes

View File

@@ -0,0 +1,254 @@
/**
* External dependencies
*/
import type { ForwardedRef } from 'react';
/**
* WordPress dependencies
*/
import { speak } from '@wordpress/a11y';
import { check, moreVertical, plus } from '@wordpress/icons';
import { __, _x, sprintf } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import DropdownMenu from '../../dropdown-menu';
import MenuGroup from '../../menu-group';
import MenuItem from '../../menu-item';
import { HStack } from '../../h-stack';
import { Heading } from '../../heading';
import { useToolsPanelHeader } from './hook';
import type { WordPressComponentProps } from '../../context';
import { contextConnect } from '../../context';
import { ResetLabel } from '../styles';
import type {
ToolsPanelControlsGroupProps,
ToolsPanelHeaderProps,
} from '../types';
const DefaultControlsGroup = ( {
itemClassName,
items,
toggleItem,
}: ToolsPanelControlsGroupProps ) => {
if ( ! items.length ) {
return null;
}
const resetSuffix = <ResetLabel aria-hidden>{ __( 'Reset' ) }</ResetLabel>;
return (
<>
{ items.map( ( [ label, hasValue ] ) => {
if ( hasValue ) {
return (
<MenuItem
key={ label }
className={ itemClassName }
role="menuitem"
label={ sprintf(
// translators: %s: The name of the control being reset e.g. "Padding".
__( 'Reset %s' ),
label
) }
onClick={ () => {
toggleItem( label );
speak(
sprintf(
// translators: %s: The name of the control being reset e.g. "Padding".
__( '%s reset to default' ),
label
),
'assertive'
);
} }
suffix={ resetSuffix }
>
{ label }
</MenuItem>
);
}
return (
<MenuItem
key={ label }
icon={ check }
className={ itemClassName }
role="menuitemcheckbox"
isSelected
aria-disabled
>
{ label }
</MenuItem>
);
} ) }
</>
);
};
const OptionalControlsGroup = ( {
items,
toggleItem,
}: ToolsPanelControlsGroupProps ) => {
if ( ! items.length ) {
return null;
}
return (
<>
{ items.map( ( [ label, isSelected ] ) => {
const itemLabel = isSelected
? sprintf(
// translators: %s: The name of the control being hidden and reset e.g. "Padding".
__( 'Hide and reset %s' ),
label
)
: sprintf(
// translators: %s: The name of the control to display e.g. "Padding".
__( 'Show %s' ),
label
);
return (
<MenuItem
key={ label }
icon={ isSelected ? check : null }
isSelected={ isSelected }
label={ itemLabel }
onClick={ () => {
if ( isSelected ) {
speak(
sprintf(
// translators: %s: The name of the control being reset e.g. "Padding".
__( '%s hidden and reset to default' ),
label
),
'assertive'
);
} else {
speak(
sprintf(
// translators: %s: The name of the control being reset e.g. "Padding".
__( '%s is now visible' ),
label
),
'assertive'
);
}
toggleItem( label );
} }
role="menuitemcheckbox"
>
{ label }
</MenuItem>
);
} ) }
</>
);
};
const ToolsPanelHeader = (
props: WordPressComponentProps< ToolsPanelHeaderProps, 'h2' >,
forwardedRef: ForwardedRef< any >
) => {
const {
areAllOptionalControlsHidden,
defaultControlsItemClassName,
dropdownMenuClassName,
hasMenuItems,
headingClassName,
headingLevel = 2,
label: labelText,
menuItems,
resetAll,
toggleItem,
dropdownMenuProps,
...headerProps
} = useToolsPanelHeader( props );
if ( ! labelText ) {
return null;
}
const defaultItems = Object.entries( menuItems?.default || {} );
const optionalItems = Object.entries( menuItems?.optional || {} );
const dropDownMenuIcon = areAllOptionalControlsHidden ? plus : moreVertical;
const dropDownMenuLabelText = sprintf(
// translators: %s: The name of the tool e.g. "Color" or "Typography".
_x( '%s options', 'Button label to reveal tool panel options' ),
labelText
);
const dropdownMenuDescriptionText = areAllOptionalControlsHidden
? __( 'All options are currently hidden' )
: undefined;
const canResetAll = [ ...defaultItems, ...optionalItems ].some(
( [ , isSelected ] ) => isSelected
);
return (
<HStack { ...headerProps } ref={ forwardedRef }>
<Heading level={ headingLevel } className={ headingClassName }>
{ labelText }
</Heading>
{ hasMenuItems && (
<DropdownMenu
{ ...dropdownMenuProps }
icon={ dropDownMenuIcon }
label={ dropDownMenuLabelText }
menuProps={ { className: dropdownMenuClassName } }
toggleProps={ {
isSmall: true,
describedBy: dropdownMenuDescriptionText,
} }
>
{ () => (
<>
<MenuGroup label={ labelText }>
<DefaultControlsGroup
items={ defaultItems }
toggleItem={ toggleItem }
itemClassName={
defaultControlsItemClassName
}
/>
<OptionalControlsGroup
items={ optionalItems }
toggleItem={ toggleItem }
/>
</MenuGroup>
<MenuGroup>
<MenuItem
aria-disabled={ ! canResetAll }
// @ts-expect-error - TODO: If this "tertiary" style is something we really want to allow on MenuItem,
// we should rename it and explicitly allow it as an official API. All the other Button variants
// don't make sense in a MenuItem context, and should be disallowed.
variant={ 'tertiary' }
onClick={ () => {
if ( canResetAll ) {
resetAll();
speak(
__( 'All options reset' ),
'assertive'
);
}
} }
>
{ __( 'Reset all' ) }
</MenuItem>
</MenuGroup>
</>
) }
</DropdownMenu>
) }
</HStack>
);
};
const ConnectedToolsPanelHeader = contextConnect(
ToolsPanelHeader,
'ToolsPanelHeader'
);
export default ConnectedToolsPanelHeader;

View File

@@ -0,0 +1,56 @@
/**
* WordPress dependencies
*/
import { useMemo } from '@wordpress/element';
/**
* Internal dependencies
*/
import * as styles from '../styles';
import { useToolsPanelContext } from '../context';
import type { WordPressComponentProps } from '../../context';
import { useContextSystem } from '../../context';
import { useCx } from '../../utils/hooks/use-cx';
import type { ToolsPanelHeaderProps } from '../types';
export function useToolsPanelHeader(
props: WordPressComponentProps< ToolsPanelHeaderProps, 'h2' >
) {
const {
className,
headingLevel = 2,
...otherProps
} = useContextSystem( props, 'ToolsPanelHeader' );
const cx = useCx();
const classes = useMemo( () => {
return cx( styles.ToolsPanelHeader, className );
}, [ className, cx ] );
const dropdownMenuClassName = useMemo( () => {
return cx( styles.DropdownMenu );
}, [ cx ] );
const headingClassName = useMemo( () => {
return cx( styles.ToolsPanelHeading );
}, [ cx ] );
const defaultControlsItemClassName = useMemo( () => {
return cx( styles.DefaultControlsItem );
}, [ cx ] );
const { menuItems, hasMenuItems, areAllOptionalControlsHidden } =
useToolsPanelContext();
return {
...otherProps,
areAllOptionalControlsHidden,
defaultControlsItemClassName,
dropdownMenuClassName,
hasMenuItems,
headingClassName,
headingLevel,
menuItems,
className: classes,
};
}

View File

@@ -0,0 +1 @@
export { default } from './component';

View File

@@ -0,0 +1,77 @@
# ToolsPanelItem
<div class="callout callout-alert">
This feature is still experimental. “Experimental” means this is an early
implementation subject to drastic and breaking changes.
</div>
<br />
This component acts as a wrapper and controls the display of items to be contained
within a ToolsPanel. An item is displayed if it is flagged as a default control
or the corresponding panel menu item, provided via context, is toggled on for
this item.
## Usage
See [`tools-panel/README.md#usage`](/packages/components/src/tools-panel/tools-panel/)
for how to use `ToolsPanelItem`.
## Props
### `hasValue`: `() => boolean`
This is called when building the `ToolsPanel` menu to determine the item's
initial checked state.
- Required: Yes
### `isShownByDefault`: `boolean`
This prop identifies the current item as being displayed by default. This means
it will show regardless of whether it has a value set or is toggled on in the
panel's menu.
- Required: No
- Default: `false`
### `label`: `string`
The supplied label is dual purpose.
It is used as:
1. the human-readable label for the panel's dropdown menu
2. a key to locate the corresponding item in the panel's menu context to
determine if the panel item should be displayed.
A panel item's `label` should be unique among all items within a single panel.
- Required: Yes
### `onDeselect`: `() => void`
Called when this item is deselected in the `ToolsPanel` menu. This is normally
used to reset the panel item control's value.
- Required: No
### `onSelect`: `() => void`
A callback to take action when this item is selected in the `ToolsPanel` menu.
- Required: No
### `panelId`: `string | null`
Panel items will ensure they are only registering with their intended panel by
comparing the `panelId` props set on both the item and the panel itself, or if the `panelId` is explicitly `null`. This
allows items to be injected from a shared source.
- Required: No
### `resetAllFilter`: `( attributes?: any ) => any`
A `ToolsPanel` will collect each item's `resetAllFilter` and pass an array of
these functions through to the panel's `resetAll` callback. They can then be
iterated over to perform additional tasks.
- Required: No
- Default: `() => {}`

View File

@@ -0,0 +1,46 @@
/**
* External dependencies
*/
import type { ForwardedRef } from 'react';
/**
* Internal dependencies
*/
import { useToolsPanelItem } from './hook';
import { View } from '../../view';
import type { WordPressComponentProps } from '../../context';
import { contextConnect } from '../../context';
import type { ToolsPanelItemProps } from '../types';
// This wraps controls to be conditionally displayed within a tools panel. It
// prevents props being applied to HTML elements that would make them invalid.
const UnconnectedToolsPanelItem = (
props: WordPressComponentProps< ToolsPanelItemProps, 'div' >,
forwardedRef: ForwardedRef< any >
) => {
const {
children,
isShown,
shouldRenderPlaceholder,
...toolsPanelItemProps
} = useToolsPanelItem( props );
if ( ! isShown ) {
return shouldRenderPlaceholder ? (
<View { ...toolsPanelItemProps } ref={ forwardedRef } />
) : null;
}
return (
<View { ...toolsPanelItemProps } ref={ forwardedRef }>
{ children }
</View>
);
};
export const ToolsPanelItem = contextConnect(
UnconnectedToolsPanelItem,
'ToolsPanelItem'
);
export default ToolsPanelItem;

View File

@@ -0,0 +1,208 @@
/**
* WordPress dependencies
*/
import { usePrevious } from '@wordpress/compose';
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
} from '@wordpress/element';
/**
* Internal dependencies
*/
import * as styles from '../styles';
import { useToolsPanelContext } from '../context';
import type { WordPressComponentProps } from '../../context';
import { useContextSystem } from '../../context';
import { useCx } from '../../utils/hooks/use-cx';
import type { ToolsPanelItemProps } from '../types';
const noop = () => {};
export function useToolsPanelItem(
props: WordPressComponentProps< ToolsPanelItemProps, 'div' >
) {
const {
className,
hasValue,
isShownByDefault = false,
label,
panelId,
resetAllFilter = noop,
onDeselect,
onSelect,
...otherProps
} = useContextSystem( props, 'ToolsPanelItem' );
const {
panelId: currentPanelId,
menuItems,
registerResetAllFilter,
deregisterResetAllFilter,
registerPanelItem,
deregisterPanelItem,
flagItemCustomization,
isResetting,
shouldRenderPlaceholderItems: shouldRenderPlaceholder,
firstDisplayedItem,
lastDisplayedItem,
__experimentalFirstVisibleItemClass,
__experimentalLastVisibleItemClass,
} = useToolsPanelContext();
// hasValue is a new function on every render, so do not add it as a
// dependency to the useCallback hook! If needed, we should use a ref.
// eslint-disable-next-line react-hooks/exhaustive-deps
const hasValueCallback = useCallback( hasValue, [ panelId ] );
// resetAllFilter is a new function on every render, so do not add it as a
// dependency to the useCallback hook! If needed, we should use a ref.
// eslint-disable-next-line react-hooks/exhaustive-deps
const resetAllFilterCallback = useCallback( resetAllFilter, [ panelId ] );
const previousPanelId = usePrevious( currentPanelId );
const hasMatchingPanel =
currentPanelId === panelId || currentPanelId === null;
// Registering the panel item allows the panel to include it in its
// automatically generated menu and determine its initial checked status.
//
// This is performed in a layout effect to ensure that the panel item
// is registered before it is rendered preventing a rendering glitch.
// See: https://github.com/WordPress/gutenberg/issues/56470
useLayoutEffect( () => {
if ( hasMatchingPanel && previousPanelId !== null ) {
registerPanelItem( {
hasValue: hasValueCallback,
isShownByDefault,
label,
panelId,
} );
}
return () => {
if (
( previousPanelId === null && !! currentPanelId ) ||
currentPanelId === panelId
) {
deregisterPanelItem( label );
}
};
}, [
currentPanelId,
hasMatchingPanel,
isShownByDefault,
label,
hasValueCallback,
panelId,
previousPanelId,
registerPanelItem,
deregisterPanelItem,
] );
useEffect( () => {
if ( hasMatchingPanel ) {
registerResetAllFilter( resetAllFilterCallback );
}
return () => {
if ( hasMatchingPanel ) {
deregisterResetAllFilter( resetAllFilterCallback );
}
};
}, [
registerResetAllFilter,
deregisterResetAllFilter,
resetAllFilterCallback,
hasMatchingPanel,
] );
// Note: `label` is used as a key when building menu item state in
// `ToolsPanel`.
const menuGroup = isShownByDefault ? 'default' : 'optional';
const isMenuItemChecked = menuItems?.[ menuGroup ]?.[ label ];
const wasMenuItemChecked = usePrevious( isMenuItemChecked );
const isRegistered = menuItems?.[ menuGroup ]?.[ label ] !== undefined;
const isValueSet = hasValue();
const wasValueSet = usePrevious( isValueSet );
const newValueSet = isValueSet && ! wasValueSet;
// Notify the panel when an item's value has been set.
useEffect( () => {
if ( ! newValueSet ) {
return;
}
flagItemCustomization( label, menuGroup );
}, [ newValueSet, menuGroup, label, flagItemCustomization ] );
// Determine if the panel item's corresponding menu is being toggled and
// trigger appropriate callback if it is.
useEffect( () => {
// We check whether this item is currently registered as items rendered
// via fills can persist through the parent panel being remounted.
// See: https://github.com/WordPress/gutenberg/pull/45673
if ( ! isRegistered || isResetting || ! hasMatchingPanel ) {
return;
}
if ( isMenuItemChecked && ! isValueSet && ! wasMenuItemChecked ) {
onSelect?.();
}
if ( ! isMenuItemChecked && wasMenuItemChecked ) {
onDeselect?.();
}
}, [
hasMatchingPanel,
isMenuItemChecked,
isRegistered,
isResetting,
isValueSet,
wasMenuItemChecked,
onSelect,
onDeselect,
] );
// The item is shown if it is a default control regardless of whether it
// has a value. Optional items are shown when they are checked or have
// a value.
const isShown = isShownByDefault
? menuItems?.[ menuGroup ]?.[ label ] !== undefined
: isMenuItemChecked;
const cx = useCx();
const classes = useMemo( () => {
const shouldApplyPlaceholderStyles =
shouldRenderPlaceholder && ! isShown;
const firstItemStyle =
firstDisplayedItem === label && __experimentalFirstVisibleItemClass;
const lastItemStyle =
lastDisplayedItem === label && __experimentalLastVisibleItemClass;
return cx(
styles.ToolsPanelItem,
shouldApplyPlaceholderStyles && styles.ToolsPanelItemPlaceholder,
! shouldApplyPlaceholderStyles && className,
firstItemStyle,
lastItemStyle
);
}, [
isShown,
shouldRenderPlaceholder,
className,
cx,
firstDisplayedItem,
lastDisplayedItem,
__experimentalFirstVisibleItemClass,
__experimentalLastVisibleItemClass,
label,
] );
return {
...otherProps,
isShown,
shouldRenderPlaceholder,
className: classes,
};
}

View File

@@ -0,0 +1,2 @@
export { default } from './component';
export { useToolsPanelItem } from './hook';

View File

@@ -0,0 +1,205 @@
# ToolsPanel
<div class="callout callout-alert">
This feature is still experimental. “Experimental” means this is an early
implementation subject to drastic and breaking changes.
</div>
<br />
These panels provide progressive discovery options for their children. For
example the controls provided via block supports.
## Development guidelines
The `ToolsPanel` creates a container with a header including a
dropdown menu. The menu is generated automatically from the panel's children
matching the `ToolsPanelItem` component type.
Each menu item allows for the display of the corresponding child to be
toggled on or off. The control's `onSelect` and `onDeselect` callbacks are fired
allowing for greater control over the child e.g. resetting block attributes when
a block support control is toggled off.
Whether a child control is initially displayed or not is dependent upon
if there has previously been a value set or the child has been flagged as
displaying by default through the `isShownByDefault` prop. Determining whether a
child has a value is done via the `hasValue` function provided through the
child's props.
Components that are not wrapped within a `ToolsPanelItem` are still rendered
however they will not be represented within, or controlled by, the `ToolsPanel`
menu. An example scenario that benefits from this could be displaying
introduction or help text within a panel.
### ToolsPanel Layout
The `ToolsPanel` has a two-column grid layout. By default, `ToolsPanelItem`
components within the panel are styled to span both columns as this fits the
majority of use-cases. Most non-control elements, such as help text, will be
rendered as children of the related control's `ToolsPanelItem` and not require
additional styling.
Suppose an element is related to multiple controls (e.g. a contrast checker), or
the panel itself (e.g. a panel description). In that case, these will be
rendered into the panel without a wrapping `ToolsPanelItem`. They'll then only
span a single column by default. If this is undesirable, those elements will
likely need a small style tweak, e.g. `grid-column: 1 / -1;`
The usage example below will illustrate a non-`ToolsPanelItem` description
paragraph, controls that should display in a single row, and others spanning
both columns.
## Usage
```jsx
/**
* External dependencies
*/
import styled from '@emotion/styled';
/**
* WordPress dependencies
*/
import {
__experimentalBoxControl as BoxControl,
__experimentalToolsPanel as ToolsPanel,
__experimentalToolsPanelItem as ToolsPanelItem,
__experimentalUnitControl as UnitControl,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
const PanelDescription = styled.div`
grid-column: span 2;
`;
const SingleColumnItem = styled( ToolsPanelItem )`
grid-column: span 1;
`;
export function DimensionPanel() {
const [ height, setHeight ] = useState();
const [ width, setWidth ] = useState();
const [ padding, setPadding ] = useState();
const [ margin, setMargin ] = useState();
const resetAll = () => {
setHeight( undefined );
setWidth( undefined );
setPadding( undefined );
setMargin( undefined );
};
return (
<ToolsPanel label={ __( 'Dimensions' ) } resetAll={ resetAll }>
<PanelDescription>
Select dimensions or spacing related settings from the
menu for additional controls.
</PanelDescription>
<SingleColumnItem
hasValue={ () => !! height }
label={ __( 'Height' ) }
onDeselect={ () => setHeight( undefined ) }
isShownByDefault
>
<UnitControl
label={ __( 'Height' ) }
onChange={ setHeight }
value={ height }
/>
</SingleColumnItem>
<SingleColumnItem
hasValue={ () => !! width }
label={ __( 'Width' ) }
onDeselect={ () => setWidth( undefined ) }
isShownByDefault
>
<UnitControl
label={ __( 'Width' ) }
onChange={ setWidth }
value={ width }
/>
</SingleColumnItem>
<ToolsPanelItem
hasValue={ () => !! padding }
label={ __( 'Padding' ) }
onDeselect={ () => setPadding( undefined ) }
>
<BoxControl
label={ __( 'Padding' ) }
onChange={ setPadding }
values={ padding }
allowReset={ false }
/>
</ToolsPanelItem>
<ToolsPanelItem
hasValue={ () => !! margin }
label={ __( 'Margin' ) }
onDeselect={ () => setMargin( undefined ) }
>
<BoxControl
label={ __( 'Margin' ) }
onChange={ setMargin }
values={ margin }
allowReset={ false }
/>
</ToolsPanelItem>
</ToolsPanel>
);
}
```
## 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
- Default: `false`
### `dropdownMenuProps`: `{}`
The popover props to configure panel's `DropdownMenu`.
- Type: `DropdownMenuProps`
- Required: No
### `headingLevel`: `1 | 2 | 3 | 4 | 5 | 6 | '1' | '2' | '3' | '4' | '5' | '6'`
The heading level of the panel's header.
- Required: No
- Default: `2`
### `label`: `string`
Text to be displayed within the panel's header and as the `aria-label` for the
panel's dropdown menu.
- Required: Yes
### `panelId`: `string | null`
If a `panelId` is set, it is passed through the `ToolsPanelContext` and used
to restrict panel items. When a `panelId` is set, items can only register
themselves if the `panelId` is explicitly `null` or the item's `panelId` matches
exactly.
- Required: No
### `resetAll`: `( filters?: ResetAllFilter[] ) => void`
A function to call when the `Reset all` menu option is selected. As an argument, it receives an array containing the `resetAllFilter` callbacks of all the valid registered `ToolsPanelItems`.
- 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.
Note that placeholder items won't apply the `className` that would be
normally applied to a visible `ToolsPanelItem` via the `className` prop.
- Required: No
- Default: `false`

View File

@@ -0,0 +1,101 @@
/**
* External dependencies
*/
import type { ForwardedRef } from 'react';
/**
* Internal dependencies
*/
import ToolsPanelHeader from '../tools-panel-header';
import { ToolsPanelContext } from '../context';
import { useToolsPanel } from './hook';
import { Grid } from '../../grid';
import type { WordPressComponentProps } from '../../context';
import { contextConnect } from '../../context';
import type { ToolsPanelProps } from '../types';
const UnconnectedToolsPanel = (
props: WordPressComponentProps< ToolsPanelProps, 'div' >,
forwardedRef: ForwardedRef< any >
) => {
const {
children,
label,
panelContext,
resetAllItems,
toggleItem,
headingLevel,
dropdownMenuProps,
...toolsPanelProps
} = useToolsPanel( props );
return (
<Grid { ...toolsPanelProps } columns={ 2 } ref={ forwardedRef }>
<ToolsPanelContext.Provider value={ panelContext }>
<ToolsPanelHeader
label={ label }
resetAll={ resetAllItems }
toggleItem={ toggleItem }
headingLevel={ headingLevel }
dropdownMenuProps={ dropdownMenuProps }
/>
{ children }
</ToolsPanelContext.Provider>
</Grid>
);
};
/**
* The `ToolsPanel` is a container component that displays its children preceded
* by a header. The header includes a dropdown menu which is automatically
* generated from the panel's inner `ToolsPanelItems`.
*
* ```jsx
* import { __ } from '@wordpress/i18n';
* import {
* __experimentalToolsPanel as ToolsPanel,
* __experimentalToolsPanelItem as ToolsPanelItem,
* __experimentalUnitControl as UnitControl
* } from '@wordpress/components';
*
* function Example() {
* const [ height, setHeight ] = useState();
* const [ width, setWidth ] = useState();
*
* const resetAll = () => {
* setHeight();
* setWidth();
* }
*
* return (
* <ToolsPanel label={ __( 'Dimensions' ) } resetAll={ resetAll }>
* <ToolsPanelItem
* hasValue={ () => !! height }
* label={ __( 'Height' ) }
* onDeselect={ () => setHeight() }
* >
* <UnitControl
* label={ __( 'Height' ) }
* onChange={ setHeight }
* value={ height }
* />
* </ToolsPanelItem>
* <ToolsPanelItem
* hasValue={ () => !! width }
* label={ __( 'Width' ) }
* onDeselect={ () => setWidth() }
* >
* <UnitControl
* label={ __( 'Width' ) }
* onChange={ setWidth }
* value={ width }
* />
* </ToolsPanelItem>
* </ToolsPanel>
* );
* }
* ```
*/
export const ToolsPanel = contextConnect( UnconnectedToolsPanel, 'ToolsPanel' );
export default ToolsPanel;

View File

@@ -0,0 +1,376 @@
/**
* WordPress dependencies
*/
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from '@wordpress/element';
/**
* Internal dependencies
*/
import * as styles from '../styles';
import type { WordPressComponentProps } from '../../context';
import { useContextSystem } from '../../context';
import { useCx } from '../../utils/hooks/use-cx';
import type {
ToolsPanelItem,
ToolsPanelMenuItemKey,
ToolsPanelMenuItems,
ToolsPanelMenuItemsConfig,
ToolsPanelProps,
ResetAllFilter,
} from '../types';
const DEFAULT_COLUMNS = 2;
const generateMenuItems = ( {
panelItems,
shouldReset,
currentMenuItems,
menuItemOrder,
}: ToolsPanelMenuItemsConfig ) => {
const newMenuItems: ToolsPanelMenuItems = { default: {}, optional: {} };
const menuItems: ToolsPanelMenuItems = { default: {}, optional: {} };
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;
} );
// Loop the known, previously registered items first to maintain menu order.
menuItemOrder.forEach( ( key ) => {
if ( newMenuItems.default.hasOwnProperty( key ) ) {
menuItems.default[ key ] = newMenuItems.default[ key ];
}
if ( newMenuItems.optional.hasOwnProperty( key ) ) {
menuItems.optional[ key ] = newMenuItems.optional[ key ];
}
} );
// Loop newMenuItems object adding any that aren't in the known items order.
Object.keys( newMenuItems.default ).forEach( ( key ) => {
if ( ! menuItems.default.hasOwnProperty( key ) ) {
menuItems.default[ key ] = newMenuItems.default[ key ];
}
} );
Object.keys( newMenuItems.optional ).forEach( ( key ) => {
if ( ! menuItems.optional.hasOwnProperty( key ) ) {
menuItems.optional[ key ] = newMenuItems.optional[ key ];
}
} );
return menuItems;
};
const isMenuItemTypeEmpty = (
obj?: ToolsPanelMenuItems[ ToolsPanelMenuItemKey ]
) => obj && Object.keys( obj ).length === 0;
export function useToolsPanel(
props: WordPressComponentProps< ToolsPanelProps, 'div' >
) {
const {
className,
headingLevel = 2,
resetAll,
panelId,
hasInnerWrapper = false,
shouldRenderPlaceholderItems = false,
__experimentalFirstVisibleItemClass,
__experimentalLastVisibleItemClass,
...otherProps
} = useContextSystem( props, 'ToolsPanel' );
const isResetting = useRef( false );
const wasResetting = isResetting.current;
// `isResetting` is cleared via this hook to effectively batch together
// the resetAll task. Without this, the flag is cleared after the first
// control updates and forces a rerender with subsequent controls then
// believing they need to reset, unfortunately using stale data.
useEffect( () => {
if ( wasResetting ) {
isResetting.current = false;
}
}, [ wasResetting ] );
// Allow panel items to register themselves.
const [ panelItems, setPanelItems ] = useState< ToolsPanelItem[] >( [] );
const [ menuItemOrder, setMenuItemOrder ] = useState< string[] >( [] );
const [ resetAllFilters, setResetAllFilters ] = useState<
ResetAllFilter[]
>( [] );
const registerPanelItem = useCallback(
( item: ToolsPanelItem ) => {
// Add item to panel items.
setPanelItems( ( items ) => {
const newItems = [ ...items ];
// If an item with this label has already been registered, remove it
// first. This can happen when an item is moved between the default
// and optional groups.
const existingIndex = newItems.findIndex(
( oldItem ) => oldItem.label === item.label
);
if ( existingIndex !== -1 ) {
newItems.splice( existingIndex, 1 );
}
return [ ...newItems, item ];
} );
// Track the initial order of item registration. This is used for
// maintaining menu item order later.
setMenuItemOrder( ( items ) => {
if ( items.includes( item.label ) ) {
return items;
}
return [ ...items, item.label ];
} );
},
[ setPanelItems, setMenuItemOrder ]
);
// Panels need to deregister on unmount to avoid orphans in menu state.
// This is an issue when panel items are being injected via SlotFills.
const deregisterPanelItem = useCallback(
( label: string ) => {
// When switching selections between components injecting matching
// controls, e.g. both panels have a "padding" control, the
// deregistration of the first panel doesn't occur until after the
// registration of the next.
setPanelItems( ( items ) => {
const newItems = [ ...items ];
const index = newItems.findIndex(
( item ) => item.label === label
);
if ( index !== -1 ) {
newItems.splice( index, 1 );
}
return newItems;
} );
},
[ setPanelItems ]
);
const registerResetAllFilter = useCallback(
( newFilter: ResetAllFilter ) => {
setResetAllFilters( ( filters ) => {
return [ ...filters, newFilter ];
} );
},
[ setResetAllFilters ]
);
const deregisterResetAllFilter = useCallback(
( filterToRemove: ResetAllFilter ) => {
setResetAllFilters( ( filters ) => {
return filters.filter(
( filter ) => filter !== filterToRemove
);
} );
},
[ setResetAllFilters ]
);
// Manage and share display state of menu items representing child controls.
const [ menuItems, setMenuItems ] = useState< ToolsPanelMenuItems >( {
default: {},
optional: {},
} );
// Setup menuItems state as panel items register themselves.
useEffect( () => {
setMenuItems( ( prevState ) => {
const items = generateMenuItems( {
panelItems,
shouldReset: false,
currentMenuItems: prevState,
menuItemOrder,
} );
return items;
} );
}, [ panelItems, setMenuItems, menuItemOrder ] );
// Force a menu item to be checked.
// This is intended for use with default panel items. They are displayed
// separately to optional items and have different display states,
// we need to update that when their value is customized.
const flagItemCustomization = useCallback(
( label: string, group: ToolsPanelMenuItemKey = 'default' ) => {
setMenuItems( ( items ) => {
const newState = {
...items,
[ group ]: {
...items[ group ],
[ label ]: true,
},
};
return newState;
} );
},
[ setMenuItems ]
);
// Whether all optional menu items are hidden or not must be tracked
// in order to later determine if the panel display is empty and handle
// conditional display of a plus icon to indicate the presence of further
// menu items.
const [ areAllOptionalControlsHidden, setAreAllOptionalControlsHidden ] =
useState( false );
useEffect( () => {
if (
isMenuItemTypeEmpty( menuItems?.default ) &&
! isMenuItemTypeEmpty( menuItems?.optional )
) {
const allControlsHidden = ! Object.entries(
menuItems.optional
).some( ( [ , isSelected ] ) => isSelected );
setAreAllOptionalControlsHidden( allControlsHidden );
}
}, [ menuItems, setAreAllOptionalControlsHidden ] );
const cx = useCx();
const classes = useMemo( () => {
const wrapperStyle =
hasInnerWrapper &&
styles.ToolsPanelWithInnerWrapper( DEFAULT_COLUMNS );
const emptyStyle =
isMenuItemTypeEmpty( menuItems?.default ) &&
areAllOptionalControlsHidden &&
styles.ToolsPanelHiddenInnerWrapper;
return cx(
styles.ToolsPanel( DEFAULT_COLUMNS ),
wrapperStyle,
emptyStyle,
className
);
}, [
areAllOptionalControlsHidden,
className,
cx,
hasInnerWrapper,
menuItems,
] );
// Toggle the checked state of a menu item which is then used to determine
// display of the item within the panel.
const toggleItem = useCallback(
( label: string ) => {
const currentItem = panelItems.find(
( item ) => item.label === label
);
if ( ! currentItem ) {
return;
}
const menuGroup = currentItem.isShownByDefault
? 'default'
: 'optional';
const newMenuItems = {
...menuItems,
[ menuGroup ]: {
...menuItems[ menuGroup ],
[ label ]: ! menuItems[ menuGroup ][ label ],
},
};
setMenuItems( newMenuItems );
},
[ menuItems, panelItems, setMenuItems ]
);
// Resets display of children and executes resetAll callback if available.
const resetAllItems = useCallback( () => {
if ( typeof resetAll === 'function' ) {
isResetting.current = true;
resetAll( resetAllFilters );
}
// Turn off display of all non-default items.
const resetMenuItems = generateMenuItems( {
panelItems,
menuItemOrder,
shouldReset: true,
} );
setMenuItems( resetMenuItems );
}, [ panelItems, resetAllFilters, resetAll, setMenuItems, menuItemOrder ] );
// Assist ItemGroup styling when there are potentially hidden placeholder
// items by identifying first & last items that are toggled on for display.
const getFirstVisibleItemLabel = ( items: ToolsPanelItem[] ) => {
const optionalItems = menuItems.optional || {};
const firstItem = items.find(
( item ) => item.isShownByDefault || !! optionalItems[ item.label ]
);
return firstItem?.label;
};
const firstDisplayedItem = getFirstVisibleItemLabel( panelItems );
const lastDisplayedItem = getFirstVisibleItemLabel(
[ ...panelItems ].reverse()
);
const panelContext = useMemo(
() => ( {
areAllOptionalControlsHidden,
deregisterPanelItem,
deregisterResetAllFilter,
firstDisplayedItem,
flagItemCustomization,
hasMenuItems: !! panelItems.length,
isResetting: isResetting.current,
lastDisplayedItem,
menuItems,
panelId,
registerPanelItem,
registerResetAllFilter,
shouldRenderPlaceholderItems,
__experimentalFirstVisibleItemClass,
__experimentalLastVisibleItemClass,
} ),
[
areAllOptionalControlsHidden,
deregisterPanelItem,
deregisterResetAllFilter,
firstDisplayedItem,
flagItemCustomization,
lastDisplayedItem,
menuItems,
panelId,
panelItems,
registerResetAllFilter,
registerPanelItem,
shouldRenderPlaceholderItems,
__experimentalFirstVisibleItemClass,
__experimentalLastVisibleItemClass,
]
);
return {
...otherProps,
headingLevel,
panelContext,
resetAllItems,
toggleItem,
className: classes,
};
}

View File

@@ -0,0 +1,2 @@
export { default } from './component';
export { useToolsPanel } from './hook';

View File

@@ -0,0 +1,202 @@
/**
* External dependencies
*/
import type { ReactNode } from 'react';
/**
* Internal dependencies
*/
import type { HeadingSize } from '../heading/types';
import type { DropdownMenu } from '../dropdown-menu';
export type ResetAllFilter = ( attributes?: any ) => any;
type ResetAll = ( filters?: ResetAllFilter[] ) => void;
export type ToolsPanelProps = {
/**
* The child elements.
*/
children: ReactNode;
/**
* The dropdown menu props to configure the panel's `DropdownMenu`.
*/
dropdownMenuProps?: React.ComponentProps< typeof DropdownMenu >;
/**
* Flags that the items in this ToolsPanel will be contained within an inner
* wrapper element allowing the panel to lay them out accordingly.
*
* @default false
*/
hasInnerWrapper?: boolean;
/**
* The heading level of the panel's header.
*
* @default 2
*/
headingLevel?: HeadingSize;
/**
* Text to be displayed within the panel's header and as the `aria-label`
* for the panel's dropdown menu.
*/
label: string;
/**
* If a `panelId` is set, it is passed through the `ToolsPanelContext` and
* used to restrict panel items. When a `panelId` is set, items can only
* register themselves if the `panelId` is explicitly `null` or the item's
* `panelId` matches exactly.
*/
panelId?: string | null;
/**
* A function to call when the `Reset all` menu option is selected. As an
* argument, it receives an array containing the `resetAllFilter` callbacks
* of all the valid registered `ToolsPanelItems`.
*/
resetAll: ResetAll;
/**
* Advises the `ToolsPanel` that its child `ToolsPanelItem`s should render
* placeholder content instead of null when they are toggled off and hidden.
* Note that placeholder items won't apply the `className` that would be
* normally applied to a visible `ToolsPanelItem` via the `className` prop.
*
* @default false
*/
shouldRenderPlaceholderItems?: boolean;
/**
* Experimental prop allowing for a custom CSS class to be applied to the
* first visible `ToolsPanelItem` within the `ToolsPanel`.
*/
__experimentalFirstVisibleItemClass?: string;
/**
* Experimental prop allowing for a custom CSS class to be applied to the
* last visible `ToolsPanelItem` within the `ToolsPanel`.
*/
__experimentalLastVisibleItemClass?: string;
};
export type ToolsPanelHeaderProps = {
/**
* The dropdown menu props to configure the panel's `DropdownMenu`.
*/
dropdownMenuProps?: React.ComponentProps< typeof DropdownMenu >;
/**
* The heading level of the panel's header.
*
* @default 2
*/
headingLevel?: HeadingSize;
/**
* Text to be displayed within the panel header. It is also passed along as
* the `label` for the panel header's `DropdownMenu`.
*/
label: string;
/**
* The `resetAll` prop provides the callback to execute when the "Reset all"
* menu item is selected. Its purpose is to facilitate resetting any control
* values for items contained within this header's panel.
*/
resetAll: ResetAll;
/**
* This is executed when an individual control's menu item is toggled. It
* will update the panel's menu item state and call the panel item's
* `onSelect` or `onDeselect` callbacks as appropriate.
*/
toggleItem: ( label: string ) => void;
};
export type ToolsPanelItem = {
/**
* This is called when building the `ToolsPanel` menu to determine the
* item's initial checked state.
*/
hasValue: () => boolean;
/**
* This prop identifies the current item as being displayed by default. This
* means it will show regardless of whether it has a value set or is toggled
* on in the panel's menu.
*
* @default false
*/
isShownByDefault?: boolean;
/**
* The supplied label is dual purpose. It is used as:
* 1. the human-readable label for the panel's dropdown menu
* 2. a key to locate the corresponding item in the panel's menu context to
* determine if the panel item should be displayed.
* A panel item's `label` should be unique among all items within a single
* panel.
*/
label: string;
/**
* Panel items will ensure they are only registering with their intended panel
* by comparing the `panelId` props set on both the item and the panel itself,
* or if the `panelId` is explicitly `null`. This allows items to be injected
* from a shared source.
*/
panelId?: string | null;
};
export type ToolsPanelItemProps = ToolsPanelItem & {
/**
* The child elements.
*/
children?: ReactNode;
/**
* Called when this item is deselected in the `ToolsPanel` menu. This is
* normally used to reset the panel item control's value.
*/
onDeselect?: () => void;
/**
* A callback to take action when this item is selected in the `ToolsPanel`
* menu.
*/
onSelect?: () => void;
/**
* A `ToolsPanel` will collect each item's `resetAllFilter` and pass an
* array of these functions through to the panel's `resetAll` callback. They
* can then be iterated over to perform additional tasks.
*
* @default noop
*/
resetAllFilter?: ResetAllFilter;
};
export type ToolsPanelMenuItemKey = 'default' | 'optional';
export type ToolsPanelMenuItems = {
[ menuItemKey in ToolsPanelMenuItemKey ]: { [ key: string ]: boolean };
};
export type ToolsPanelContext = {
panelId?: string | null;
menuItems: ToolsPanelMenuItems;
hasMenuItems: boolean;
registerPanelItem: ( item: ToolsPanelItem ) => void;
deregisterPanelItem: ( label: string ) => void;
registerResetAllFilter: ( filter: ResetAllFilter ) => void;
deregisterResetAllFilter: ( filter: ResetAllFilter ) => void;
flagItemCustomization: (
label: string,
group?: ToolsPanelMenuItemKey
) => void;
isResetting: boolean;
shouldRenderPlaceholderItems: boolean;
areAllOptionalControlsHidden: boolean;
firstDisplayedItem?: string;
lastDisplayedItem?: string;
__experimentalFirstVisibleItemClass?: string;
__experimentalLastVisibleItemClass?: string;
};
export type ToolsPanelControlsGroupProps = {
itemClassName?: string;
items: [ string, boolean ][];
toggleItem: ( label: string ) => void;
};
export type ToolsPanelMenuItemsConfig = {
panelItems: ToolsPanelItem[];
shouldReset: boolean;
currentMenuItems?: ToolsPanelMenuItems;
menuItemOrder: string[];
};