Skip to content

Commit

Permalink
Split top level component into more granular sub-components:
Browse files Browse the repository at this point in the history
- trigger button
- submenu trigger item
- popover

Update types accordingly
  • Loading branch information
ciampo committed Oct 25, 2024
1 parent edac41f commit ed07d73
Show file tree
Hide file tree
Showing 5 changed files with 264 additions and 142 deletions.
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
108 changes: 108 additions & 0 deletions packages/components/src/dropdown-menu-v2/menu-popover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* External dependencies
*/
import * as Ariakit from '@ariakit/react';

/**
* WordPress dependencies
*/
import {
useContext,
useMemo,
forwardRef,
useCallback,
} from '@wordpress/element';

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

export const DropdownMenuPopover = forwardRef<
HTMLDivElement,
WordPressComponentProps< DropdownMenuPopoverProps, 'div', false >
>( function DropdownMenuPopover(
{ gutter, children, shift, modal = true, ...otherProps },
ref
) {
const dropdownMenuContext = useContext( DropdownMenuContext );

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

// 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 = Ariakit.useStoreState(
dropdownMenuContext.store,
'currentPlacement'
).split( '-' )[ 0 ];

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 computedDirection = Ariakit.useStoreState(
dropdownMenuContext.store,
'rtl'
)
? 'rtl'
: 'ltr';

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

return (
<Ariakit.Menu
{ ...otherProps }
ref={ ref }
modal={ modal }
store={ dropdownMenuContext.store }
// Root menu has an 8px distance from its trigger,
// otherwise 0 (which causes the submenu to slightly overlap)
gutter={ gutter ?? ( dropdownMenuContext.store.parent ? 0 : 8 ) }
// Align nested menu by the same (but opposite) amount
// as the menu container's padding.
shift={ shift ?? ( dropdownMenuContext.store.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={ dropdownMenuContext.variant }
>
<Styled.MenuPopoverInnerWrapper { ...renderProps } />
</Styled.MenuPopoverOuterWrapper>
) }
>
{ children }
</Ariakit.Menu>
);
} );
58 changes: 58 additions & 0 deletions packages/components/src/dropdown-menu-v2/submenu-trigger-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* External dependencies
*/
import * as Ariakit from '@ariakit/react';

/**
* WordPress dependencies
*/
import { forwardRef, useContext } from '@wordpress/element';
import { chevronRightSmall } from '@wordpress/icons';

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

export const DropdownMenuSubmenuTriggerItem = forwardRef<
HTMLDivElement,
WordPressComponentProps< DropdownMenuSubmenuTriggerItemProps, 'div', false >
>( function DropdownMenuSubmenuTriggerItem( { suffix, ...otherProps }, ref ) {
const dropdownMenuContext = useContext( DropdownMenuContext );

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

return (
<Ariakit.MenuButton
ref={ ref }
accessibleWhenDisabled
store={ dropdownMenuContext.store }
render={
<DropdownMenuItem
{ ...otherProps }
// The menu item needs to register and be part of the parent menu
store={ dropdownMenuContext.store.parent }
suffix={
<>
{ suffix }
<Styled.SubmenuChevronIcon
aria-hidden="true"
icon={ chevronRightSmall }
size={ 24 }
preserveAspectRatio="xMidYMid slice"
/>
</>
}
/>
}
/>
);
} );
Loading

0 comments on commit ed07d73

Please sign in to comment.