Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DropdownMenu: refactor to more granular sub-components #66383

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/components/src/dropdown-menu-v2/checkbox-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ export const DropdownMenuCheckboxItem = forwardRef<
const focusVisibleFixProps = useTemporaryFocusVisibleFix( { onBlur } );
const dropdownMenuContext = useContext( DropdownMenuContext );

if ( ! dropdownMenuContext?.store ) {
throw new Error(
'DropdownMenu.CheckboxItem can only be rendered inside a DropdownMenu component'
);
}

return (
<Styled.DropdownMenuCheckboxItem
ref={ ref }
Expand Down
7 changes: 7 additions & 0 deletions packages/components/src/dropdown-menu-v2/group-label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ export const DropdownMenuGroupLabel = forwardRef<
WordPressComponentProps< DropdownMenuGroupLabelProps, 'div', false >
>( function DropdownMenuGroup( props, ref ) {
const dropdownMenuContext = useContext( DropdownMenuContext );

if ( ! dropdownMenuContext?.store ) {
throw new Error(
'DropdownMenu.GroupLabel can only be rendered inside a DropdownMenu component'
);
}

return (
<Styled.DropdownMenuGroupLabel
ref={ ref }
Expand Down
7 changes: 7 additions & 0 deletions packages/components/src/dropdown-menu-v2/group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ export const DropdownMenuGroup = forwardRef<
WordPressComponentProps< DropdownMenuGroupProps, 'div', false >
>( function DropdownMenuGroup( props, ref ) {
const dropdownMenuContext = useContext( DropdownMenuContext );

if ( ! dropdownMenuContext?.store ) {
throw new Error(
'DropdownMenu.Group can only be rendered inside a DropdownMenu component'
);
}

return (
<Styled.DropdownMenuGroup
ref={ ref }
Expand Down
157 changes: 26 additions & 131 deletions packages/components/src/dropdown-menu-v2/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,21 @@
* External dependencies
*/
import * as Ariakit from '@ariakit/react';
import { useStoreState } from '@ariakit/react';

/**
* WordPress dependencies
*/
import {
useContext,
useMemo,
cloneElement,
isValidElement,
useCallback,
} from '@wordpress/element';
import { isRTL } from '@wordpress/i18n';
import { chevronRightSmall } from '@wordpress/icons';
import { useContext, useMemo } from '@wordpress/element';
import { isRTL as isRTLFn } from '@wordpress/i18n';

/**
* Internal dependencies
*/
import { useContextSystem, contextConnect } from '../context';
import type { WordPressComponentProps } from '../context';
import { useContextSystem, contextConnectWithoutRef } from '../context';
import type {
DropdownMenuContext as DropdownMenuContextType,
DropdownMenuProps,
} from './types';
import * as Styled from './styles';
import { DropdownMenuContext } from './context';
import { DropdownMenuItem } from './item';
import { DropdownMenuCheckboxItem } from './checkbox-item';
Expand All @@ -36,48 +26,37 @@ import { DropdownMenuGroupLabel } from './group-label';
import { DropdownMenuSeparator } from './separator';
import { DropdownMenuItemLabel } from './item-label';
import { DropdownMenuItemHelpText } from './item-help-text';
import { DropdownMenuTriggerButton } from './trigger-button';
import { DropdownMenuSubmenuTriggerItem } from './submenu-trigger-item';
import { DropdownMenuPopover } from './menu-popover';

const UnconnectedDropdownMenu = (
props: WordPressComponentProps< DropdownMenuProps, 'div', false >,
ref: React.ForwardedRef< HTMLDivElement >
) => {
const UnconnectedDropdownMenu = ( props: DropdownMenuProps ) => {
const {
// Store props
open,
defaultOpen = false,
onOpenChange,
placement,

// Menu trigger props
trigger,

// Menu props
gutter,
children,
shift,
modal = true,

// From internal components context
variant,

// Rest
...otherProps
} = useContextSystem<
// @ts-expect-error TODO: missing 'className'
typeof props & Pick< DropdownMenuContextType, 'variant' >
>( props, 'DropdownMenu' );

const parentContext = useContext( DropdownMenuContext );

const computedDirection = isRTL() ? 'rtl' : 'ltr';
const rtl = isRTLFn();

// If an explicit value for the `placement` prop is not passed,
// apply a default placement of `bottom-start` for the root dropdown,
// and of `right-start` for nested dropdowns.
let computedPlacement =
props.placement ??
( parentContext?.store ? 'right-start' : 'bottom-start' );
placement ?? ( parentContext?.store ? 'right-start' : 'bottom-start' );

// Swap left/right in case of RTL direction
if ( computedDirection === 'rtl' ) {
if ( rtl ) {
if ( /right/.test( computedPlacement ) ) {
computedPlacement = computedPlacement.replace(
'right',
Expand All @@ -100,120 +79,36 @@ const UnconnectedDropdownMenu = (
setOpen( willBeOpen ) {
onOpenChange?.( willBeOpen );
},
rtl: computedDirection === 'rtl',
rtl,
} );

const contextValue = useMemo(
() => ( { store: dropdownMenuStore, variant } ),
[ dropdownMenuStore, variant ]
);

// Extract the side from the applied placement — useful for animations.
// Using `currentPlacement` instead of `placement` to make sure that we
// use the final computed placement (including "flips" etc).
const appliedPlacementSide = useStoreState(
dropdownMenuStore,
'currentPlacement'
).split( '-' )[ 0 ];

if (
dropdownMenuStore.parent &&
! ( isValidElement( trigger ) && DropdownMenuItem === trigger.type )
) {
// eslint-disable-next-line no-console
console.warn(
'For nested DropdownMenus, the `trigger` should always be a `DropdownMenuItem`.'
);
}

const hideOnEscape = useCallback(
( event: React.KeyboardEvent< Element > ) => {
// Pressing Escape can cause unexpected consequences (ie. exiting
// full screen mode on MacOs, close parent modals...).
event.preventDefault();
// Returning `true` causes the menu to hide.
return true;
},
[]
);

const wrapperProps = useMemo(
() => ( {
dir: computedDirection,
style: {
direction:
computedDirection as React.CSSProperties[ 'direction' ],
},
} ),
[ computedDirection ]
);

return (
<>
{ /* Menu trigger */ }
<Ariakit.MenuButton
ref={ ref }
store={ dropdownMenuStore }
render={
dropdownMenuStore.parent
? cloneElement( trigger, {
// Add submenu arrow, unless a `suffix` is explicitly specified
suffix: (
<>
{ trigger.props.suffix }
<Styled.SubmenuChevronIcon
aria-hidden="true"
icon={ chevronRightSmall }
size={ 24 }
preserveAspectRatio="xMidYMid slice"
/>
</>
),
} )
: trigger
}
/>

{ /* Menu popover */ }
<Ariakit.Menu
{ ...otherProps }
modal={ modal }
store={ dropdownMenuStore }
// Root menu has an 8px distance from its trigger,
// otherwise 0 (which causes the submenu to slightly overlap)
gutter={ gutter ?? ( dropdownMenuStore.parent ? 0 : 8 ) }
// Align nested menu by the same (but opposite) amount
// as the menu container's padding.
shift={ shift ?? ( dropdownMenuStore.parent ? -4 : 0 ) }
hideOnHoverOutside={ false }
data-side={ appliedPlacementSide }
wrapperProps={ wrapperProps }
hideOnEscape={ hideOnEscape }
unmountOnHide
render={ ( renderProps ) => (
// Two wrappers are needed for the entry animation, where the menu
// container scales with a different factor than its contents.
// The {...renderProps} are passed to the inner wrapper, so that the
// menu element is the direct parent of the menu item elements.
<Styled.MenuPopoverOuterWrapper variant={ variant }>
<Styled.MenuPopoverInnerWrapper { ...renderProps } />
</Styled.MenuPopoverOuterWrapper>
) }
>
<DropdownMenuContext.Provider value={ contextValue }>
{ children }
</DropdownMenuContext.Provider>
</Ariakit.Menu>
</>
<DropdownMenuContext.Provider value={ contextValue }>
{ children }
</DropdownMenuContext.Provider>
);
};

export const DropdownMenuV2 = Object.assign(
contextConnect( UnconnectedDropdownMenu, 'DropdownMenu' ),
contextConnectWithoutRef( UnconnectedDropdownMenu, 'DropdownMenu' ),
{
Context: Object.assign( DropdownMenuContext, {
displayName: 'DropdownMenuV2.Context',
} ),
TriggerButton: Object.assign( DropdownMenuTriggerButton, {
displayName: 'DropdownMenuV2.TriggerButton',
} ),
SubmenuTriggerItem: Object.assign( DropdownMenuSubmenuTriggerItem, {
displayName: 'DropdownMenuV2.SubmenuTriggerItem',
} ),
Popover: Object.assign( DropdownMenuPopover, {
displayName: 'DropdownMenuV2.Popover',
} ),
Item: Object.assign( DropdownMenuItem, {
displayName: 'DropdownMenuV2.Item',
} ),
Expand Down
11 changes: 10 additions & 1 deletion packages/components/src/dropdown-menu-v2/item-help-text.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
/**
* WordPress dependencies
*/
import { forwardRef } from '@wordpress/element';
import { forwardRef, useContext } from '@wordpress/element';

/**
* Internal dependencies
*/
import type { WordPressComponentProps } from '../context';
import { DropdownMenuContext } from './context';
import * as Styled from './styles';

export const DropdownMenuItemHelpText = forwardRef<
HTMLSpanElement,
WordPressComponentProps< { children: React.ReactNode }, 'span', true >
>( function DropdownMenuItemHelpText( props, ref ) {
const dropdownMenuContext = useContext( DropdownMenuContext );

if ( ! dropdownMenuContext?.store ) {
throw new Error(
'DropdownMenu.ItemHelpText can only be rendered inside a DropdownMenu component'
);
}

return (
<Styled.DropdownMenuItemHelpText
numberOfLines={ 2 }
Expand Down
11 changes: 10 additions & 1 deletion packages/components/src/dropdown-menu-v2/item-label.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
/**
* WordPress dependencies
*/
import { forwardRef } from '@wordpress/element';
import { forwardRef, useContext } from '@wordpress/element';

/**
* Internal dependencies
*/
import type { WordPressComponentProps } from '../context';
import { DropdownMenuContext } from './context';
import * as Styled from './styles';

export const DropdownMenuItemLabel = forwardRef<
HTMLSpanElement,
WordPressComponentProps< { children: React.ReactNode }, 'span', true >
>( function DropdownMenuItemLabel( props, ref ) {
const dropdownMenuContext = useContext( DropdownMenuContext );

if ( ! dropdownMenuContext?.store ) {
throw new Error(
'DropdownMenu.ItemLabel can only be rendered inside a DropdownMenu component'
);
}

return (
<Styled.DropdownMenuItemLabel
numberOfLines={ 1 }
Expand Down
12 changes: 10 additions & 2 deletions packages/components/src/dropdown-menu-v2/item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,29 @@ export const DropdownMenuItem = forwardRef<
HTMLDivElement,
WordPressComponentProps< DropdownMenuItemProps, 'div', false >
>( function DropdownMenuItem(
{ prefix, suffix, children, onBlur, hideOnClick = true, ...props },
{ prefix, suffix, children, onBlur, hideOnClick = true, store, ...props },
ref
) {
// TODO: Remove when https://github.com/ariakit/ariakit/issues/4083 is fixed
const focusVisibleFixProps = useTemporaryFocusVisibleFix( { onBlur } );
const dropdownMenuContext = useContext( DropdownMenuContext );

if ( ! dropdownMenuContext?.store ) {
throw new Error(
'DropdownMenu.Item can only be rendered inside a DropdownMenu component'
);
}

const computedStore = store ?? dropdownMenuContext.store;

return (
<Styled.DropdownMenuItem
ref={ ref }
{ ...props }
{ ...focusVisibleFixProps }
accessibleWhenDisabled
hideOnClick={ hideOnClick }
store={ dropdownMenuContext?.store }
store={ computedStore }
>
<Styled.ItemPrefixWrapper>{ prefix }</Styled.ItemPrefixWrapper>

Expand Down
Loading
Loading