Skip to content

Commit

Permalink
DropdownMenu: refactor to TypeScript (#50187)
Browse files Browse the repository at this point in the history
  • Loading branch information
chad1008 committed May 17, 2023
1 parent 2452a92 commit 22c837b
Show file tree
Hide file tree
Showing 7 changed files with 312 additions and 102 deletions.
2 changes: 2 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
### Internal

- `Modal`: Remove children container's unused class name ([#50655](https://github.com/WordPress/gutenberg/pull/50655)).
- `DropdownMenu`: Convert to TypeScript ([#50187](https://github.com/WordPress/gutenberg/pull/50187)).


## 24.0.0 (2023-05-10)

Expand Down
34 changes: 12 additions & 22 deletions packages/components/src/dropdown-menu/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,80 +131,70 @@ const MyDropdownMenu = () => (

The component accepts the following props:

#### icon
#### `icon`: `string | null`

The [Dashicon](https://developer.wordpress.org/resource/dashicons/) icon slug to be shown in the collapsed menu button.

- Type: `String|null`
- Required: No
- Default: `"menu"`

See also: [https://developer.wordpress.org/resource/dashicons/](https://developer.wordpress.org/resource/dashicons/)

#### label
#### `label`: `string`

A human-readable label to present as accessibility text on the focused collapsed menu button.

- Type: `String`
- Required: Yes

#### controls
#### `controls:` `DropdownOption[] | DropdownOption[][]`

An array of objects describing the options to be shown in the expanded menu.
An array or nested array of objects describing the options to be shown in the expanded menu.

Each object should include an `icon` [Dashicon](https://developer.wordpress.org/resource/dashicons/) slug string, a human-readable `title` string, `isDisabled` boolean flag and an `onClick` function callback to invoke when the option is selected.

A valid DropdownMenu must specify one or the other of a `controls` or `children` prop.

- Type: `Array`
A valid DropdownMenu must specify a `controls` or `children` prop, or both.
- Required: No

#### children
#### `children`: `( callbackProps: DropdownCallbackProps ) => ReactNode`

A [function render prop](https://reactjs.org/docs/render-props.html#using-props-other-than-render) which should return an element or elements valid for use in a DropdownMenu: `MenuItem`, `MenuItemsChoice`, or `MenuGroup`. Its first argument is a props object including the same values as given to a [`Dropdown`'s `renderContent`](/packages/components/src/dropdown#rendercontent) (`isOpen`, `onToggle`, `onClose`).

A valid DropdownMenu must specify one or the other of a `controls` or `children` prop.
A valid DropdownMenu must specify a `controls` or `children` prop, or both.

- Type: `Function`
- Required: No

See also: [https://developer.wordpress.org/resource/dashicons/](https://developer.wordpress.org/resource/dashicons/)

#### className
#### `className`: `string`

A class name to apply to the dropdown menu's toggle element wrapper.

- Type: `String`
- Required: No

#### popoverProps
#### `popoverProps`: `DropdownProps[ 'popoverProps' ]`

Properties of `popoverProps` object will be passed as props to the nested `Popover` component.
Use this object to modify props available for the `Popover` component that are not already exposed in the `DropdownMenu` component, e.g.: the direction in which the popover should open relative to its parent node set with `position` prop.

- Type: `Object`
- Required: No

#### toggleProps
#### `toggleProps`: `ToggleProps`

Properties of `toggleProps` object will be passed as props to the nested `Button` component in the `renderToggle` implementation of the `Dropdown` component used internally.
Use this object to modify props available for the `Button` component that are not already exposed in the `DropdownMenu` component, e.g.: the tooltip text displayed on hover set with `tooltip` prop.

- Type: `Object`
- Required: No

#### menuProps
#### `menuProps`: `NavigableContainerProps`

Properties of `menuProps` object will be passed as props to the nested `NavigableMenu` component in the `renderContent` implementation of the `Dropdown` component used internally.
Use this object to modify props available for the `NavigableMenu` component that are not already exposed in the `DropdownMenu` component, e.g.: the orientation of the menu set with `orientation` prop.

- Type: `Object`
- Required: No

#### disableOpenOnArrowDown
#### `disableOpenOnArrowDown`: `boolean`

In some contexts, the arrow down key used to open the dropdown menu might need to be disabled—for example when that key is used to perform another action.

- Type: `boolean`
- Required: No
- Default: `false`
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// @ts-nocheck
/**
* External dependencies
*/
Expand All @@ -15,9 +14,12 @@ import { menu } from '@wordpress/icons';
import Button from '../button';
import Dropdown from '../dropdown';
import { NavigableMenu } from '../navigable-container';
import type { DropdownMenuProps, DropdownOption } from './types';

function mergeProps( defaultProps = {}, props = {} ) {
const mergedProps = {
function mergeProps<
T extends { className?: string; [ key: string ]: unknown }
>( defaultProps: Partial< T > = {}, props: T = {} as T ) {
const mergedProps: T = {
...defaultProps,
...props,
};
Expand All @@ -32,17 +34,92 @@ function mergeProps( defaultProps = {}, props = {} ) {
return mergedProps;
}

function isFunction( maybeFunc: unknown ): maybeFunc is () => void {
return typeof maybeFunc === 'function';
}

/**
* Whether the argument is a function.
*
* @param {*} maybeFunc The argument to check.
* @return {boolean} True if the argument is a function, false otherwise.
* The DropdownMenu displays a list of actions (each contained in a MenuItem,
* MenuItemsChoice, or MenuGroup) in a compact way. It appears in a Popover
* after the user has interacted with an element (a button or icon) or when
* they perform a specific action.
*
* Render a Dropdown Menu with a set of controls:
*
* ```jsx
* import { DropdownMenu } from '@wordpress/components';
* import {
* more,
* arrowLeft,
* arrowRight,
* arrowUp,
* arrowDown,
* } from '@wordpress/icons';
*
* const MyDropdownMenu = () => (
* <DropdownMenu
* icon={ more }
* label="Select a direction"
* controls={ [
* {
* title: 'Up',
* icon: arrowUp,
* onClick: () => console.log( 'up' ),
* },
* {
* title: 'Right',
* icon: arrowRight,
* onClick: () => console.log( 'right' ),
* },
* {
* title: 'Down',
* icon: arrowDown,
* onClick: () => console.log( 'down' ),
* },
* {
* title: 'Left',
* icon: arrowLeft,
* onClick: () => console.log( 'left' ),
* },
* ] }
* />
* );
* ```
*
* Alternatively, specify a `children` function which returns elements valid for
* use in a DropdownMenu: `MenuItem`, `MenuItemsChoice`, or `MenuGroup`.
*
* ```jsx
* import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components';
* import { more, arrowUp, arrowDown, trash } from '@wordpress/icons';
*
* const MyDropdownMenu = () => (
* <DropdownMenu icon={ more } label="Select a direction">
* { ( { onClose } ) => (
* <>
* <MenuGroup>
* <MenuItem icon={ arrowUp } onClick={ onClose }>
* Move Up
* </MenuItem>
* <MenuItem icon={ arrowDown } onClick={ onClose }>
* Move Down
* </MenuItem>
* </MenuGroup>
* <MenuGroup>
* <MenuItem icon={ trash } onClick={ onClose }>
* Remove
* </MenuItem>
* </MenuGroup>
* </>
* ) }
* </DropdownMenu>
* );
* ```
*
*/
function isFunction( maybeFunc ) {
return typeof maybeFunc === 'function';
}

function DropdownMenu( dropdownMenuProps ) {
function DropdownMenu( dropdownMenuProps: DropdownMenuProps ) {
const {
children,
className,
Expand All @@ -62,13 +139,18 @@ function DropdownMenu( dropdownMenuProps ) {
}

// Normalize controls to nested array of objects (sets of controls)
let controlSets;
let controlSets: DropdownOption[][];
if ( controls?.length ) {
// @ts-expect-error The check below is needed because `DropdownMenus`
// rendered by `ToolBarGroup` receive controls as a nested array.
controlSets = controls;
if ( ! Array.isArray( controlSets[ 0 ] ) ) {
controlSets = [ controlSets ];
// This is not ideal, but at this point we know that `controls` is
// not a nested array, even if TypeScript doesn't.
controlSets = [ controls as DropdownOption[] ];
}
}

const mergedPopoverProps = mergeProps(
{
className: 'components-dropdown-menu__popover',
Expand All @@ -81,7 +163,7 @@ function DropdownMenu( dropdownMenuProps ) {
className={ classnames( 'components-dropdown-menu', className ) }
popoverProps={ mergedPopoverProps }
renderToggle={ ( { isOpen, onToggle } ) => {
const openOnArrowDown = ( event ) => {
const openOnArrowDown = ( event: React.KeyboardEvent ) => {
if ( disableOpenOnArrowDown ) {
return;
}
Expand Down Expand Up @@ -110,18 +192,22 @@ function DropdownMenu( dropdownMenuProps ) {
<Toggle
{ ...mergedToggleProps }
icon={ icon }
onClick={ ( event ) => {
onToggle( event );
if ( mergedToggleProps.onClick ) {
mergedToggleProps.onClick( event );
}
} }
onKeyDown={ ( event ) => {
openOnArrowDown( event );
if ( mergedToggleProps.onKeyDown ) {
mergedToggleProps.onKeyDown( event );
}
} }
onClick={
( ( event ) => {
onToggle();
if ( mergedToggleProps.onClick ) {
mergedToggleProps.onClick( event );
}
} ) as React.MouseEventHandler< HTMLButtonElement >
}
onKeyDown={
( ( event ) => {
openOnArrowDown( event );
if ( mergedToggleProps.onKeyDown ) {
mergedToggleProps.onKeyDown( event );
}
} ) as React.KeyboardEventHandler< HTMLButtonElement >
}
aria-haspopup="true"
aria-expanded={ isOpen }
label={ label }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
/**
* External dependencies
*/
import type { ComponentMeta, ComponentStory } from '@storybook/react';
/**
* Internal dependencies
*/
import DropdownMenu from '../';
import { MenuGroup, MenuItem } from '../../';
import DropdownMenu from '..';
import { MenuGroup, MenuItem } from '../..';

/**
* WordPress dependencies
Expand All @@ -16,37 +20,24 @@ import {
trash,
} from '@wordpress/icons';

export default {
const meta: ComponentMeta< typeof DropdownMenu > = {
title: 'Components/DropdownMenu',
component: DropdownMenu,
parameters: {
controls: { expanded: true },
docs: { source: { state: 'open' } },
},
argTypes: {
className: { control: { type: 'text' } },
children: { control: { type: null } },
disableOpenOnArrowDown: { control: { type: 'boolean' } },
icon: {
options: [ 'menu', 'chevronDown', 'more' ],
mapping: { menu, chevronDown, more },
control: { type: 'select' },
},
menuProps: {
control: { type: 'object' },
},
noIcons: { control: { type: 'boolean' } },
popoverProps: {
control: { type: 'object' },
},
text: { control: { type: 'text' } },
toggleProps: {
control: { type: 'object' },
},
},
parameters: {
controls: { expanded: true },
docs: { source: { state: 'open' } },
},
};
export default meta;

const Template = ( props ) => (
const Template: ComponentStory< typeof DropdownMenu > = ( props ) => (
<div style={ { height: 150 } }>
<DropdownMenu { ...props } />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,19 @@ import { arrowLeft, arrowRight, arrowUp, arrowDown } from '@wordpress/icons';
/**
* Internal dependencies
*/
import DropdownMenu from '../';
import { MenuItem } from '../../';
import DropdownMenu from '..';
import { MenuItem } from '../..';

describe( 'DropdownMenu', () => {
it( 'should not render when neither controls nor children are assigned', () => {
render( <DropdownMenu /> );
render( <DropdownMenu label="Open dropdown" /> );

// The button toggle should not even be rendered
expect( screen.queryByRole( 'button' ) ).not.toBeInTheDocument();
} );

it( 'should not render when controls are empty and children is not specified', () => {
render( <DropdownMenu controls={ [] } /> );
render( <DropdownMenu label="Open dropdown" controls={ [] } /> );

// The button toggle should not even be rendered
expect( screen.queryByRole( 'button' ) ).not.toBeInTheDocument();
Expand Down Expand Up @@ -56,7 +56,7 @@ describe( 'DropdownMenu', () => {
},
];

render( <DropdownMenu controls={ controls } /> );
render( <DropdownMenu label="Open dropdown" controls={ controls } /> );

// Move focus on the toggle button
await user.tab();
Expand All @@ -78,6 +78,7 @@ describe( 'DropdownMenu', () => {

render(
<DropdownMenu
label="Open dropdown"
children={ ( { onClose } ) => <MenuItem onClick={ onClose } /> }
/>
);
Expand Down
Loading

0 comments on commit 22c837b

Please sign in to comment.