Skip to content
115 changes: 115 additions & 0 deletions packages/react-core/src/next/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import React from 'react';
import { css } from '@patternfly/react-styles';
import { Menu, MenuContent, MenuProps } from '../../../components/Menu';
import { Popper } from '../../../helpers/Popper/Popper';

export interface DropdownProps extends MenuProps {
/** Anything which can be rendered in a dropdown. */
children?: React.ReactNode;
/** Classes applied to root element of dropdown. */
className?: string;
/** Renderer for a custom dropdown toggle. Forwards a ref to the toggle. */
toggle: (toggleRef: React.RefObject<any>) => React.ReactNode;
/** Flag to indicate if menu is opened.*/
isOpen?: boolean;
/** Function callback called when user selects item. */
onSelect?: (event?: React.MouseEvent<Element, MouseEvent>, itemId?: string | number) => void;
/** Callback to allow the dropdown component to change the open state of the menu.
* Triggered by clicking outside of the menu, or by pressing either tab or escape. */
onOpenChange?: (isOpen: boolean) => void;
/** Indicates if the menu should be without the outer box-shadow. */
isPlain?: boolean;
/** Indicates if the menu should be scrollable. */
isScrollable?: boolean;
/** Min width of the menu. */
minWidth?: string;
}

export const Dropdown: React.FunctionComponent<DropdownProps> = ({
children,
className,
onSelect,
isOpen,
toggle,
onOpenChange,
isPlain,
isScrollable,
minWidth,
...props
}: DropdownProps) => {
const localMenuRef = React.useRef<HTMLDivElement>();
const toggleRef = React.useRef<HTMLButtonElement>();
const containerRef = React.useRef<HTMLDivElement>();

const menuRef = (props.innerRef as React.RefObject<HTMLDivElement>) || localMenuRef;
React.useEffect(() => {
const handleMenuKeys = (event: KeyboardEvent) => {
if (!isOpen && toggleRef.current?.contains(event.target as Node)) {
// toggle was clicked open, focus on first menu item
if (event.key === 'Enter') {
setTimeout(() => {
const firstElement = menuRef.current.querySelector('li > button:not(:disabled)');
firstElement && (firstElement as HTMLElement).focus();
}, 0);
}
}
// Close the menu on tab or escape if onOpenChange is provided
if (
(isOpen && onOpenChange && menuRef.current?.contains(event.target as Node)) ||
toggleRef.current?.contains(event.target as Node)
) {
if (event.key === 'Escape' || event.key === 'Tab') {
onOpenChange(!isOpen);
toggleRef.current?.focus();
}
}
};

const handleClickOutside = (event: MouseEvent) => {
// If the event is not on the toggle and onOpenChange callback is provided, close the menu
if (isOpen && onOpenChange && !toggleRef?.current?.contains(event.target as Node)) {
if (isOpen && !menuRef.current?.contains(event.target as Node)) {
onOpenChange(false);
}
}
};

window.addEventListener('keydown', handleMenuKeys);
window.addEventListener('click', handleClickOutside);

return () => {
window.removeEventListener('keydown', handleMenuKeys);
window.removeEventListener('click', handleClickOutside);
};
}, [isOpen, menuRef, onOpenChange]);

const menu = (
<Menu
className={css(className)}
ref={menuRef}
onSelect={(event, itemId) => onSelect(event, itemId)}
isPlain={isPlain}
isScrollable={isScrollable}
{...(minWidth && {
style: {
'--pf-c-menu--MinWidth': minWidth
} as React.CSSProperties
})}
{...props}
>
<MenuContent>{children}</MenuContent>
</Menu>
);
return (
<div ref={containerRef}>
<Popper
trigger={toggle(toggleRef)}
removeFindDomNode
popper={menu}
appendTo={containerRef.current || undefined}
isVisible={isOpen}
/>
</div>
);
};
Dropdown.displayName = 'Dropdown';
25 changes: 25 additions & 0 deletions packages/react-core/src/next/components/Dropdown/DropdownGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from 'react';
import { css } from '@patternfly/react-styles';
import { MenuGroupProps, MenuGroup } from '../../../components/Menu';

export interface DropdownGroupProps extends Omit<MenuGroupProps, 'ref'> {
/** Anything which can be rendered in a dropdown group. */
children: React.ReactNode;
/** Classes applied to root element of dropdown group */
className?: string;
/** Label of the dropdown group */
label?: string;
}

export const DropdownGroup: React.FunctionComponent<DropdownGroupProps> = ({
children,
className,
label,
labelHeadingLevel = 'h1',
...props
}: DropdownGroupProps) => (
<MenuGroup className={css(className)} label={label} labelHeadingLevel={labelHeadingLevel} {...props}>
{children}
</MenuGroup>
);
DropdownGroup.displayName = 'DropdownGroup';
24 changes: 24 additions & 0 deletions packages/react-core/src/next/components/Dropdown/DropdownItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { css } from '@patternfly/react-styles';
import { MenuItemProps, MenuItem } from '../../../components/Menu';

export interface DropdownItemProps extends Omit<MenuItemProps, 'ref'> {
/** Anything which can be rendered in a dropdown item */
children?: React.ReactNode;
/** Classes applied to root element of dropdown item */
className?: string;
/** Description of the dropdown item */
description?: React.ReactNode;
}

export const DropdownItem: React.FunctionComponent<MenuItemProps> = ({
children,
className,
description,
...props
}: DropdownItemProps) => (
<MenuItem className={css(className)} description={description} {...props}>
{children}
</MenuItem>
);
DropdownItem.displayName = 'DropdownItem';
21 changes: 21 additions & 0 deletions packages/react-core/src/next/components/Dropdown/DropdownList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from 'react';
import { css } from '@patternfly/react-styles';
import { MenuListProps, MenuList } from '../../../components/Menu';

export interface DropdownListProps extends MenuListProps {
/** Anything which can be rendered in a dropdown list */
children: React.ReactNode;
/** Classes applied to root element of dropdown list */
className?: string;
}

export const DropdownList: React.FunctionComponent<MenuListProps> = ({
children,
className,
...props
}: DropdownListProps) => (
<MenuList className={css(className)} {...props}>
{children}
</MenuList>
);
DropdownList.displayName = 'DropdownList';
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
id: Dropdown
section: components
cssPrefix: pf-c-dropdown
propComponents: ['Dropdown', DropdownGroup, 'DropdownItem', 'DropdownList']
beta: true
---

import EllipsisVIcon from '@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon';

## Examples

### Basic

```ts file="./DropdownBasic.tsx"
```

### With kebab toggle

```ts file="./DropdownWithKebabToggle.tsx"
```

### With groups

```ts file="./DropdownWithGroups.tsx"
```

### With descriptions

```ts file="./DropdownWithDescriptions.tsx"
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React from 'react';
import { Dropdown, DropdownItem, DropdownList } from '@patternfly/react-core/next';
import { Divider, MenuToggle } from '@patternfly/react-core';

export const DropdownBasic: React.FunctionComponent = () => {
const [isOpen, setIsOpen] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);

const onToggleClick = () => {
setIsOpen(!isOpen);
};

const onSelect = (_event: React.MouseEvent<Element, MouseEvent> | undefined, itemId: string | number | undefined) => {
// eslint-disable-next-line no-console
console.log('selected', itemId);
setIsOpen(false);
};

return (
<Dropdown
innerRef={menuRef}
isOpen={isOpen}
onSelect={onSelect}
onOpenChange={isOpen => setIsOpen(isOpen)}
toggle={toggleRef => (
<MenuToggle ref={toggleRef} onClick={onToggleClick} isExpanded={isOpen}>
Dropdown
</MenuToggle>
)}
>
<DropdownList>
<DropdownItem itemId={0} key="link">
Link
</DropdownItem>
<DropdownItem itemId={1} key="action" to="#default-link2" onClick={ev => ev.preventDefault()}>
Action
</DropdownItem>
<DropdownItem itemId={2} isDisabled key="disabled link">
Disabled link
</DropdownItem>
<DropdownItem itemId={3} isDisabled key="disabled action" to="#default-link4">
Disabled action
</DropdownItem>
<Divider key="separator" />
<DropdownItem itemId={4} key="separated link">
Separated link
</DropdownItem>
<DropdownItem itemId={5} key="separated action">
Separated action
</DropdownItem>
</DropdownList>
</Dropdown>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import React from 'react';
import { Dropdown, DropdownItem, DropdownList } from '@patternfly/react-core/next';
import { MenuToggle } from '@patternfly/react-core';

export const DropdownWithDescriptions: React.FunctionComponent = () => {
const [isOpen, setIsOpen] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);

const onToggleClick = () => {
setIsOpen(!isOpen);
};

const onSelect = (_event: React.MouseEvent<Element, MouseEvent> | undefined, itemId: string | number | undefined) => {
// eslint-disable-next-line no-console
console.log('selected', itemId);
setIsOpen(false);
};

return (
<Dropdown
innerRef={menuRef}
isOpen={isOpen}
onSelect={onSelect}
minWidth="150px"
onOpenChange={isOpen => setIsOpen(isOpen)}
toggle={toggleRef => (
<MenuToggle ref={toggleRef} isFullWidth onClick={onToggleClick} isExpanded={isOpen}>
Dropdown
</MenuToggle>
)}
>
<DropdownList>
<DropdownItem itemId={0} key="link" description="This is a description">
Link
</DropdownItem>
<DropdownItem
itemId={1}
key="action"
description="This is a very long description that describes the menu item"
to="#default-link2"
onClick={ev => ev.preventDefault()}
>
Action
</DropdownItem>
<DropdownItem itemId={2} isDisabled description="Disabled link description" key="disabled link">
Disabled link
</DropdownItem>
<DropdownItem
itemId={3}
isDisabled
description="This is a description"
key="disabled action"
to="#default-link4"
>
Disabled action
</DropdownItem>
</DropdownList>
</Dropdown>
);
};
Loading