Skip to content

Commit

Permalink
Refactor Dropdown component to TypeScript (#45787)
Browse files Browse the repository at this point in the history
* Refactor Dropdown component to TypeScript

* Correct the text of fail()

* Rename RenderProps, as those have a different meaning

* Rename EmbeddedComponentProps to CallbackProps

* Fix failed unit test

* Correct the text to search for

* Correct the popoverProps property

* Simplify the type of popoverProps

* Add types to story exports

* Revert changes to index.native.js

* Delete index.native.tsx

* Use the CallbackProps from types.ts

* Pass parameters to stories so there's not a TypeError

* Reference the DropdownProps instead of definitin the props again

* Move a type import to the other type imports

* Change the Dropdown to accept a forwarded ref

Might be the wrong approach.

* Don't export CallbackProps

* Simplify popoverProps

* Add a CHANGELOG entry

* Commit Lena Morita's suggestion: Update packages/components/src/dropdown/index.tsx

Co-authored-by: Lena Morita <[email protected]>

* Commit Lena Morita's suggestion: Update packages/components/src/dropdown/stories/index.tsx

Co-authored-by: Lena Morita <[email protected]>

* Apply Lena Morita's suggestion to let TS infer the return type

* Commit Lena Morita's suggestion: Update packages/components/src/dropdown/index.tsx

Co-authored-by: Lena Morita <[email protected]>

* Define className and style, instead of referencing WordPressComponentProps

* Remove expandOnMobile and headerTitle in stories

* Commit Lena Morita's suggestion: Update packages/components/src/dropdown/types.ts

Co-authored-by: Lena Morita <[email protected]>

* Commit Lena Morita's suggestion: Update packages/components/src/dropdown/types.ts

Co-authored-by: Lena Morita <[email protected]>

* Apply Lena Morita's suggestion use typeof Popover

* Commit Lena Morita's suggestion: Update packages/components/src/dropdown/README.md

Co-authored-by: Lena Morita <[email protected]>

Co-authored-by: Lena Morita <[email protected]>
  • Loading branch information
kienstra and mirka authored Dec 21, 2022
1 parent 2768895 commit 79f6e5f
Show file tree
Hide file tree
Showing 9 changed files with 246 additions and 91 deletions.
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
- `TabPanel`: Refactor away from `_.find()` ([#46537](https://github.com/WordPress/gutenberg/pull/46537)).
- `BottomSheetPickerCell`: Refactor away from `_.find()` for mobile ([#46537](https://github.com/WordPress/gutenberg/pull/46537)).
- Refactor global styles context away from `_.find()` for mobile ([#46537](https://github.com/WordPress/gutenberg/pull/46537)).
- `Dropdown`: Convert to TypeScript ([#45787](https://github.com/WordPress/gutenberg/pull/45787)).

### Documentation

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ import { StyledLabel } from '../../base-control/styles/base-control-styles';
import DropdownContentWrapper from '../../dropdown/dropdown-content-wrapper';

import type { ColorObject, PaletteObject } from '../../color-palette/types';
import type { DropdownProps as DropdownComponentProps } from '../../dropdown/types';
import type { ColorProps, DropdownProps } from '../types';

const noop = () => undefined;
const getColorObject = (
colorValue: CSSProperties[ 'borderColor' ],
colors: ColorProps[ 'colors' ] | undefined,
Expand Down Expand Up @@ -165,7 +165,9 @@ const BorderControlDropdown = (
? 'bottom left'
: undefined;

const renderToggle = ( { onToggle = noop } ) => (
const renderToggle: DropdownComponentProps[ 'renderToggle' ] = ( {
onToggle,
} ) => (
<Button
onClick={ onToggle }
variant="tertiary"
Expand All @@ -183,8 +185,9 @@ const BorderControlDropdown = (
</Button>
);

// TODO: update types once Dropdown component is refactored to TypeScript.
const renderContent = ( { onClose }: { onClose: () => void } ) => (
const renderContent: DropdownComponentProps[ 'renderContent' ] = ( {
onClose,
} ) => (
<>
<DropdownContentWrapper paddingSize="medium">
<VStack className={ popoverControlsClassName } spacing={ 6 }>
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/color-palette/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type {
SinglePaletteProps,
} from './types';
import type { WordPressComponentProps } from '../ui/context';
import type { DropdownProps } from '../dropdown/types';

extend( [ namesPlugin, a11yPlugin ] );

Expand Down Expand Up @@ -134,7 +135,7 @@ export function CustomColorPickerDropdown( {
popoverProps: receivedPopoverProps,
...props
}: CustomColorPickerDropdownProps ) {
const popoverProps = useMemo(
const popoverProps = useMemo< DropdownProps[ 'popoverProps' ] >(
() => ( {
shift: true,
...( isRenderedInSidebar
Expand Down
13 changes: 3 additions & 10 deletions packages/components/src/color-palette/types.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
/**
* External dependencies
*/
import type { CSSProperties, MouseEventHandler, ReactNode } from 'react';
import type { CSSProperties, ReactNode } from 'react';

/**
* Internal dependencies
*/
import type { PopoverProps } from '../popover/types';
import type { DropdownProps } from '../dropdown/types';

export type ColorObject = {
name: string;
Expand Down Expand Up @@ -37,15 +37,8 @@ export type MultiplePalettesProps = PaletteProps & {
colors: PaletteObject[];
};

// TODO: should extend `Dropdown`'s props once it gets refactored to TypeScript
export type CustomColorPickerDropdownProps = {
export type CustomColorPickerDropdownProps = DropdownProps & {
isRenderedInSidebar: boolean;
renderContent: () => ReactNode;
popoverProps?: Omit< PopoverProps, 'children' >;
renderToggle: ( props: {
isOpen: boolean;
onToggle: MouseEventHandler< HTMLButtonElement >;
} ) => ReactNode;
};

export type ColorPaletteProps = Pick< PaletteProps, 'onChange' > & {
Expand Down
87 changes: 41 additions & 46 deletions packages/components/src/dropdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,96 +32,91 @@ const MyDropdown = () => (

The component accepts the following props. Props not included in this set will be applied to the element wrapping Popover content.

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

className of the global container

- Type: `String`
- Required: No

### contentClassName
### `contentClassName`: `string`

If you want to target the dropdown menu for styling purposes, you need to provide a contentClassName because it's not being rendered as a child of the container node.

- Type: `String`
- Required: No

### position
### `expandOnMobile`: `boolean`

The direction in which the popover should open relative to its parent node. Specify a y- and an x-axis as a space-separated string. Supports `"top"`, `"bottom"` y-axis, and `"left"`, `"center"`, `"right"` x-axis.
Opt-in prop to show popovers fullscreen on mobile.

- Type: `String`
- Required: No
- Default: `"top center"`

### renderToggle

A callback invoked to render the Dropdown Toggle Button.

- Type: `Function`
- Required: Yes
- Default: `false`

The first argument of the callback is an object containing the following properties:
### `focusOnMount`: `'firstElement' | boolean`

- `isOpen`: whether the dropdown menu is opened or not
- `onToggle`: A function switching the dropdown menu's state from open to closed and vice versa
- `onClose`: A function that closes the menu if invoked
By default, the _first tabbable element_ in the popover will receive focus when it mounts. This is the same as setting this prop to `"firstElement"`.

### renderContent
Specifying a `true` value will focus the container instead.

A callback invoked to render the content of the dropdown menu. Its first argument is the same as the `renderToggle` prop.
Specifying a `false` value disables the focus handling entirely (this should only be done when an appropriately accessible substitute behavior exists).

- Type: `Function`
- Required: Yes
- Required: No
- Default: `"firstElement"`

### expandOnMobile
### `headerTitle`: `string`

Opt-in prop to show popovers fullscreen on mobile.
Set this to customize the text that is shown in the dropdown's header when it is fullscreen on mobile.

- Type: `Boolean`
- Required: No
- Default: `false`

### headerTitle
### `onClose`: `() => void`

Set this to customize the text that is shown in the dropdown's header when it is fullscreen on mobile.
A callback invoked when the popover should be closed.

- Type: `String`
- Required: No

### focusOnMount

By default, the _first tabbable element_ in the popover will receive focus when it mounts. This is the same as setting this prop to `"firstElement"`.
### `onToggle`: `( willOpen: boolean ) => void`

Specifying a `true` value will focus the container instead.
A callback invoked when the state of the popover changes from open to closed and vice versa.

Specifying a `false` value disables the focus handling entirely (this should only be done when an appropriately accessible substitute behavior exists).
The callback receives a boolean as a parameter. If `true`, the popover will open. If `false`, the popover will close.

- Type: `'firstElement' | boolean`
- Required: No
- Default: `"firstElement"`

### popoverProps
### `popoverProps`: `WordPressComponentProps< Omit< PopoverProps, 'children' > 'div', false >`

Properties of popoverProps object will be passed as props to the `Popover` component.

Use this object to access properties/features of the `Popover` component that are not already exposed in the `Dropdown` component, e.g.: the ability to have the popover without an arrow.

- Type: `Object`
- Required: No

### onClose
### `position`: `PopoverProps[ 'position' ]`

A callback invoked when the popover should be closed.
The direction in which the popover should open relative to its parent node. Specify a y- and an x-axis as a space-separated string. Supports `"top"`, `"bottom"` y-axis, and `"left"`, `"center"`, `"right"` x-axis.

- Type: `Function`
- Required: No
- Default: `"top center"`

### onToggle
### `renderContent`: `( props: CallbackProps ) => ReactNode`

A callback invoked when the state of the popover changes from open to closed and vice versa.
A callback invoked to render the content of the dropdown menu.

The callback receives a boolean as a parameter. If `true`, the popover will open. If `false`, the popover will close.
- `isOpen`: whether the dropdown menu is opened or not
- `onToggle`: A function switching the dropdown menu's state from open to closed and vice versa
- `onClose`: A function that closes the menu if invoked

- Required: Yes

### `renderToggle`: `( props: CallbackProps ) => ReactNode`

A callback invoked to render the Dropdown Toggle Button.

Its props are the same as the `renderContent` props.

- Required: Yes

### `style`: `React.CSSProperties`

The style of the global container

- Type: `Function`
- Required: No
Original file line number Diff line number Diff line change
@@ -1,35 +1,39 @@
// @ts-nocheck
/**
* External dependencies
*/
import classnames from 'classnames';
import type { ForwardedRef } from 'react';

/**
* WordPress dependencies
*/
import { useRef, useEffect, useState } from '@wordpress/element';
import { forwardRef, useEffect, useRef, useState } from '@wordpress/element';
import { useMergeRefs } from '@wordpress/compose';

/**
* Internal dependencies
*/
import Popover from '../popover';
import type { DropdownProps } from './types';

function useObservableState( initialState, onStateChange ) {
function useObservableState(
initialState: boolean,
onStateChange?: ( newState: boolean ) => void
) {
const [ state, setState ] = useState( initialState );
return [
state,
( value ) => {
( value: boolean ) => {
setState( value );
if ( onStateChange ) {
onStateChange( value );
}
},
];
] as const;
}

export default function Dropdown( props ) {
const {
function UnforwardedDropdown(
{
renderContent,
renderToggle,
className,
Expand All @@ -42,12 +46,14 @@ export default function Dropdown( props ) {
onClose,
onToggle,
style,
} = props;
}: DropdownProps,
forwardedRef: ForwardedRef< any >
) {
// Use internal state instead of a ref to make sure that the component
// re-renders when the popover's anchor updates.
const [ fallbackPopoverAnchor, setFallbackPopoverAnchor ] =
useState( null );
const containerRef = useRef();
useState< HTMLDivElement | null >( null );
const containerRef = useRef< HTMLDivElement >();
const [ isOpen, setIsOpen ] = useObservableState( false, onToggle );

useEffect(
Expand All @@ -70,8 +76,13 @@ export default function Dropdown( props ) {
* case a dialog has opened, allowing focus to return when it's dismissed.
*/
function closeIfFocusOutside() {
if ( ! containerRef.current ) {
return;
}

const { ownerDocument } = containerRef.current;
const dialog = ownerDocument.activeElement.closest( '[role="dialog"]' );
const dialog =
ownerDocument?.activeElement?.closest( '[role="dialog"]' );
if (
! containerRef.current.contains( ownerDocument.activeElement ) &&
( ! dialog || dialog.contains( containerRef.current ) )
Expand Down Expand Up @@ -99,11 +110,15 @@ export default function Dropdown( props ) {
return (
<div
className={ classnames( 'components-dropdown', className ) }
ref={ useMergeRefs( [ setFallbackPopoverAnchor, containerRef ] ) }
ref={ useMergeRefs( [
containerRef,
forwardedRef,
setFallbackPopoverAnchor,
] ) }
// Some UAs focus the closest focusable parent when the toggle is
// clicked. Making this div focusable ensures such UAs will focus
// it and `closeIfFocusOutside` can tell if the toggle was clicked.
tabIndex="-1"
tabIndex={ -1 }
style={ style }
>
{ renderToggle( args ) }
Expand Down Expand Up @@ -136,3 +151,32 @@ export default function Dropdown( props ) {
</div>
);
}

/**
* Renders a button that opens a floating content modal when clicked.
*
* ```jsx
* import { Button, Dropdown } from '@wordpress/components';
*
* const MyDropdown = () => (
* <Dropdown
* className="my-container-class-name"
* contentClassName="my-popover-content-classname"
* position="bottom right"
* renderToggle={ ( { isOpen, onToggle } ) => (
* <Button
* variant="primary"
* onClick={ onToggle }
* aria-expanded={ isOpen }
* >
* Toggle Popover!
* </Button>
* ) }
* renderContent={ () => <div>This is the content of the popover.</div> }
* />
* );
* ```
*/
export const Dropdown = forwardRef( UnforwardedDropdown );

export default Dropdown;
Loading

1 comment on commit 79f6e5f

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flaky tests detected.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/3749062015
📝 Reported issues:

Please sign in to comment.