diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 6e46c3b70f495..d4cc445dd379d 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -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) diff --git a/packages/components/src/dropdown-menu/README.md b/packages/components/src/dropdown-menu/README.md index fc6fc4ba708c9..e1e4c7bf031b0 100644 --- a/packages/components/src/dropdown-menu/README.md +++ b/packages/components/src/dropdown-menu/README.md @@ -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` diff --git a/packages/components/src/dropdown-menu/index.js b/packages/components/src/dropdown-menu/index.tsx similarity index 54% rename from packages/components/src/dropdown-menu/index.js rename to packages/components/src/dropdown-menu/index.tsx index 481e87cd102b7..805bcd0661179 100644 --- a/packages/components/src/dropdown-menu/index.js +++ b/packages/components/src/dropdown-menu/index.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck /** * External dependencies */ @@ -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, }; @@ -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 = () => ( + * 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 = () => ( + * + * { ( { onClose } ) => ( + * <> + * + * + * Move Up + * + * + * Move Down + * + * + * + * + * Remove + * + * + * + * ) } + * + * ); + * ``` + * */ -function isFunction( maybeFunc ) { - return typeof maybeFunc === 'function'; -} -function DropdownMenu( dropdownMenuProps ) { +function DropdownMenu( dropdownMenuProps: DropdownMenuProps ) { const { children, className, @@ -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', @@ -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; } @@ -110,18 +192,22 @@ function DropdownMenu( dropdownMenuProps ) { { - 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 } diff --git a/packages/components/src/dropdown-menu/stories/index.js b/packages/components/src/dropdown-menu/stories/index.tsx similarity index 79% rename from packages/components/src/dropdown-menu/stories/index.js rename to packages/components/src/dropdown-menu/stories/index.tsx index 33477854721ee..97a51371d1ab8 100644 --- a/packages/components/src/dropdown-menu/stories/index.js +++ b/packages/components/src/dropdown-menu/stories/index.tsx @@ -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 @@ -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 ) => (
diff --git a/packages/components/src/dropdown-menu/test/index.js b/packages/components/src/dropdown-menu/test/index.tsx similarity index 89% rename from packages/components/src/dropdown-menu/test/index.js rename to packages/components/src/dropdown-menu/test/index.tsx index b40ab218ccd9f..118e991812367 100644 --- a/packages/components/src/dropdown-menu/test/index.js +++ b/packages/components/src/dropdown-menu/test/index.tsx @@ -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( ); + render( ); // 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( ); + render( ); // The button toggle should not even be rendered expect( screen.queryByRole( 'button' ) ).not.toBeInTheDocument(); @@ -56,7 +56,7 @@ describe( 'DropdownMenu', () => { }, ]; - render( ); + render( ); // Move focus on the toggle button await user.tab(); @@ -78,6 +78,7 @@ describe( 'DropdownMenu', () => { render( } /> ); diff --git a/packages/components/src/dropdown-menu/types.ts b/packages/components/src/dropdown-menu/types.ts new file mode 100644 index 0000000000000..badfcb54d6072 --- /dev/null +++ b/packages/components/src/dropdown-menu/types.ts @@ -0,0 +1,143 @@ +/** + * External dependencies + */ +import type { ReactNode } from 'react'; +/** + * Internal dependencies + */ +import type { ButtonAsButtonProps } from '../button/types'; +import type { WordPressComponentProps } from '../ui/context'; +import type { DropdownProps } from '../dropdown/types'; +import type { Props as IconProps } from '../icon'; +import type { NavigableMenuProps } from '../navigable-container/types'; + +export type DropdownOption = { + /** + * The Dashicon icon slug to be shown for the option. + */ + icon?: IconProps[ 'icon' ]; + /** + * A human-readable title to display for the option. + */ + title: string; + /** + * Whether or not the option is disabled. + * + * @default false + */ + isDisabled?: boolean; + /** + * A callback function to invoke when the option is selected. + */ + onClick?: () => void; + /** + * Whether or not the control is currently active. + */ + isActive?: boolean; + /** + * Text to use for the internal `Button` component's tooltip. + */ + label?: string; + /** + * The role to apply to the option's HTML element + */ + role?: HTMLElement[ 'role' ]; +}; + +type DropdownCallbackProps = { + isOpen: boolean; + onToggle: () => void; + onClose: () => void; +}; + +// Manually including `as` prop because `WordPressComponentProps` polymorhpism +// creates a union that is too large for TypeScript to handle. +type ToggleProps = Partial< + Omit< + WordPressComponentProps< ButtonAsButtonProps, 'button', false >, + 'label' | 'text' + > +> & { + as?: React.ElementType | keyof JSX.IntrinsicElements; +}; + +export type DropdownMenuProps = { + /** + * The Dashicon icon slug to be shown in the collapsed menu button. + * + * @default "menu" + */ + icon?: IconProps[ 'icon' ] | null; + /** + * A human-readable label to present as accessibility text on the focused + * collapsed menu button. + */ + label: string; + /** + * A class name to apply to the dropdown menu's toggle element wrapper. + */ + className?: string; + /** + * 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. + */ + popoverProps?: DropdownProps[ 'popoverProps' ]; + /** + * 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. + */ + toggleProps?: ToggleProps; + /** + * 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. + */ + menuProps?: Omit< Partial< NavigableMenuProps >, 'children' >; + /** + * 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. + * + * @default false + */ + disableOpenOnArrowDown?: boolean; + /** + * Text to display on the nested `Button` component in the `renderToggle` + * implementation of the `Dropdown` component used internally. + */ + text?: string; + /** + * Whether or not `no-icons` should be added to the menu's `className`. + */ + noIcons?: boolean; + /** + * 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` (`isOpen`, `onToggle`, `onClose`). + * + * A valid DropdownMenu must specify a `controls` or `children` prop, or both. + */ + children?: ( callbackProps: DropdownCallbackProps ) => ReactNode; + /** + * An array or nested array of objects describing the options to be shown in + * the expanded menu. Each object should include an `icon` Dashicon 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 a `controls` or `children` prop, or both. + */ + controls?: DropdownOption[] | DropdownOption[][]; +}; diff --git a/packages/components/src/toolbar/stories/index.tsx b/packages/components/src/toolbar/stories/index.tsx index fd0fb9587347d..17a8e64111eb2 100644 --- a/packages/components/src/toolbar/stories/index.tsx +++ b/packages/components/src/toolbar/stories/index.tsx @@ -82,34 +82,31 @@ Default.args = { - { - // @ts-expect-error TODO: Remove when DropdownMenu is typed - ( toggleProps ) => { - return ( - - ); - } - } + { /* There is an issue here with TS not recognizing the + * `RenderProp` being passed. + * @ts-expect-error */ } + { ( toggleProps ) => ( + + ) }