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

Support controlling open/closed state for Dropdown and DropdownMenu #54257

Merged
merged 10 commits into from
Sep 11, 2023
10 changes: 5 additions & 5 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,13 @@

## Unreleased

### Breaking changes
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Moved this entry to the previous release section, since I realised that the corresponding PR was merged before August 31st


- Make the `Popover.Slot` optional and render popovers at the bottom of the document's body by default. ([#53889](https://github.com/WordPress/gutenberg/pull/53889), [#53982](https://github.com/WordPress/gutenberg/pull/53982)).

### Enhancements

- Making Circular Option Picker a `listbox`. Note that while this changes some public API, new props are optional, and currently have default values; this will change in another patch ([#52255](https://github.com/WordPress/gutenberg/pull/52255)).
- `ToggleGroupControl`: Rewrite backdrop animation using framer motion shared layout animations, add better support for controlled and uncontrolled modes ([#50278](https://github.com/WordPress/gutenberg/pull/50278)).
- `Popover`: Add the `is-positioned` CSS class only after the popover has finished animating ([#54178](https://github.com/WordPress/gutenberg/pull/54178)).
- `Tooltip`: Replace the existing tooltip to simplify the implementation and improve accessibility while maintaining the same behaviors and API ([#48440](https://github.com/WordPress/gutenberg/pull/48440)).
- `Dropdown` and `DropdownMenu`: support controlled mode for the dropdown's open/closed state ([#54257](https://github.com/WordPress/gutenberg/pull/54257)).

### Bug Fix

Expand All @@ -32,9 +29,12 @@

## 25.7.0 (2023-08-31)

### Breaking changes

- Make the `Popover.Slot` optional and render popovers at the bottom of the document's body by default. ([#53889](https://github.com/WordPress/gutenberg/pull/53889), [#53982](https://github.com/WordPress/gutenberg/pull/53982)).

### Enhancements

- Make the `Popover.Slot` optional and render popovers at the bottom of the document's body by default. ([#53889](https://github.com/WordPress/gutenberg/pull/53889)).
- `ProgressBar`: Add transition to determinate indicator ([#53877](https://github.com/WordPress/gutenberg/pull/53877)).
- Prevent nested `SlotFillProvider` from rendering ([#53940](https://github.com/WordPress/gutenberg/pull/53940)).

Expand Down
18 changes: 18 additions & 0 deletions packages/components/src/dropdown-menu/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,21 @@ In some contexts, the arrow down key used to open the dropdown menu might need t

- Required: No
- Default: `false`

### `defaultOpen`: `boolean`

The open state of the dropdown menu when initially rendered. Use when you do not need to control its open state. It will be overridden by the `open` prop if it is specified on the component's first render.

- Required: No

### `open`: `boolean`

The controlled open state of the dropdown menu. Must be used in conjunction with `onToggle`.

- Required: No

### `onToggle`: `( willOpen: boolean ) => void`

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

- Required: No
7 changes: 7 additions & 0 deletions packages/components/src/dropdown-menu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ function UnconnectedDropdownMenu( dropdownMenuProps: DropdownMenuProps ) {
text,
noIcons,

open,
defaultOpen,
onToggle: onToggleProp,

// Context
variant,
} = useContextSystem< DropdownMenuProps & DropdownMenuInternalContext >(
Expand Down Expand Up @@ -211,6 +215,9 @@ function UnconnectedDropdownMenu( dropdownMenuProps: DropdownMenuProps ) {
</NavigableMenu>
);
} }
open={ open }
defaultOpen={ defaultOpen }
onToggle={ onToggleProp }
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* External dependencies
*/
import type { Meta, StoryFn } from '@storybook/react';

/**
* Internal dependencies
*/
Expand All @@ -25,6 +26,7 @@ const meta: Meta< typeof DropdownMenu > = {
title: 'Components/DropdownMenu',
component: DropdownMenu,
parameters: {
actions: { argTypesRegex: '^on.*' },
controls: { expanded: true },
docs: { canvas: { sourceState: 'shown' } },
},
Expand All @@ -34,6 +36,9 @@ const meta: Meta< typeof DropdownMenu > = {
mapping: { menu, chevronDown, more },
control: { type: 'select' },
},
open: { control: { type: null } },
defaultOpen: { control: { type: null } },
onToggle: { control: { type: null } },
},
};
export default meta;
Expand Down
16 changes: 16 additions & 0 deletions packages/components/src/dropdown-menu/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,22 @@ export type DropdownMenuProps = {
* A valid DropdownMenu must specify a `controls` or `children` prop, or both.
*/
controls?: DropdownOption[] | DropdownOption[][];
/**
* The controlled open state of the dropdown menu.
* Must be used in conjunction with `onToggle`.
*/
open?: boolean;
/**
* The open state of the dropdown menu when initially rendered.
* Use when you do not need to control its open state. It will be overridden
* by the `open` prop if it is specified on the component's first render.
*/
defaultOpen?: boolean;
/**
* A callback invoked when the state of the dropdown menu changes
* from open to closed and vice versa.
*/
onToggle?: ( willOpen: boolean ) => void;
};

export type DropdownMenuInternalContext = {
Expand Down
16 changes: 13 additions & 3 deletions packages/components/src/dropdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ If you want to target the dropdown menu for styling purposes, you need to provid

- Required: No

### `defaultOpen`: `boolean`

The open state of the dropdown when initially rendered. Use when you do not need to control its open state. It will be overridden by the `open` prop if it is specified on the component's first render.

- Required: No

### `expandOnMobile`: `boolean`

Opt-in prop to show popovers fullscreen on mobile.
Expand Down Expand Up @@ -74,11 +80,15 @@ A callback invoked when the popover should be closed.

- Required: No

### `onToggle`: `( willOpen: boolean ) => void`
### `open`: `boolean`

A callback invoked when the state of the popover changes from open to closed and vice versa.
The controlled open state of the dropdown. Must be used in conjunction with `onToggle`.

- Required: No

### `onToggle`: `( willOpen: boolean ) => void`

The callback receives a boolean as a parameter. If `true`, the popover will open. If `false`, the popover will close.
A callback invoked when the state of the dropdown changes from open to closed and vice versa.

- Required: No

Expand Down
50 changes: 16 additions & 34 deletions packages/components/src/dropdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,18 @@ import type { ForwardedRef } from 'react';
/**
* WordPress dependencies
*/
import { useEffect, useRef, useState } from '@wordpress/element';
import { useRef, useState } from '@wordpress/element';
import { useMergeRefs } from '@wordpress/compose';
import deprecated from '@wordpress/deprecated';

/**
* Internal dependencies
*/
import { contextConnect, useContextSystem } from '../ui/context';
import { useControlledValue } from '../utils/hooks';
import Popover from '../popover';
import type { DropdownProps, DropdownInternalContext } from './types';

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

const UnconnectedDropdown = (
props: DropdownProps,
forwardedRef: ForwardedRef< any >
Expand All @@ -51,6 +36,9 @@ const UnconnectedDropdown = (
onToggle,
style,

open,
defaultOpen,

// Deprecated props
position,

Expand All @@ -74,20 +62,12 @@ const UnconnectedDropdown = (
const [ fallbackPopoverAnchor, setFallbackPopoverAnchor ] =
useState< HTMLDivElement | null >( null );
const containerRef = useRef< HTMLDivElement >();
const [ isOpen, setIsOpen ] = useObservableState( false, onToggle );

useEffect(
() => () => {
if ( onToggle && isOpen ) {
onToggle( false );
}
},
[ onToggle, isOpen ]
);
Comment on lines -79 to -86
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice one, this is the removal that I was missing from my experiment 🎉


function toggle() {
setIsOpen( ! isOpen );
}
const [ isOpen, setIsOpen ] = useControlledValue( {
defaultValue: defaultOpen,
value: open,
onChange: onToggle,
} );

/**
* Closes the popover when focus leaves it unless the toggle was pressed or
Expand All @@ -112,13 +92,15 @@ const UnconnectedDropdown = (
}

function close() {
if ( onClose ) {
onClose();
}
onClose?.();
setIsOpen( false );
}

const args = { isOpen, onToggle: toggle, onClose: close };
const args = {
isOpen: !! isOpen,
onToggle: () => setIsOpen( ! isOpen ),
onClose: close,
};
const popoverPropsHaveAnchor =
!! popoverProps?.anchor ||
// Note: `anchorRef`, `getAnchorRect` and `anchorRect` are deprecated and
Expand Down
17 changes: 10 additions & 7 deletions packages/components/src/dropdown/stories/index.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,25 @@ const meta: Meta< typeof Dropdown > = {
position: { control: { type: null } },
renderContent: { control: { type: null } },
renderToggle: { control: { type: null } },
open: { control: { type: null } },
defaultOpen: { control: { type: null } },
onToggle: { control: { type: null } },
onClose: { control: { type: null } },
},
parameters: {
actions: { argTypesRegex: '^on.*' },
controls: {
expanded: true,
},
},
};
export default meta;

const Template: StoryFn< typeof Dropdown > = ( args ) => {
return (
<div style={ { height: 150 } }>
<Dropdown { ...args } />
</div>
);
};
const Template: StoryFn< typeof Dropdown > = ( args ) => (
<div style={ { height: 150 } }>
<Dropdown { ...args } />
</div>
);

export const Default = Template.bind( {} );
Default.args = {
Expand Down
16 changes: 12 additions & 4 deletions packages/components/src/dropdown/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,8 @@ export type DropdownProps = {
*/
onClose?: () => void;
/**
* A callback invoked when the state of the popover changes
* A callback invoked when the state of the dropdown changes
* from open to closed and vice versa.
* The callback receives a boolean as a parameter.
* If true, the popover will open.
* If false, the popover will close.
*/
onToggle?: ( willOpen: boolean ) => void;
/**
Expand Down Expand Up @@ -111,6 +108,17 @@ export type DropdownProps = {
* @deprecated
*/
position?: PopoverProps[ 'position' ];
/**
* The controlled open state of the dropdown.
* Must be used in conjunction with `onToggle`.
*/
open?: boolean;
/**
* The open state of the dropdown when initially rendered.
* Use when you do not need to control its open state. It will be overridden
* by the `open` prop if it is specified on the component's first render.
*/
defaultOpen?: boolean;
};

export type DropdownInternalContext = {
Expand Down