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 TypeScript #50187

Merged
merged 19 commits into from
May 17, 2023
Merged
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
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[][]`
chad1008 marked this conversation as resolved.
Show resolved Hide resolved

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.
chad1008 marked this conversation as resolved.
Show resolved Hide resolved
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();
chad1008 marked this conversation as resolved.
Show resolved Hide resolved
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: {
chad1008 marked this conversation as resolved.
Show resolved Hide resolved
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