diff --git a/packages/react-core/src/next/components/Dropdown/Dropdown.tsx b/packages/react-core/src/next/components/Dropdown/Dropdown.tsx new file mode 100644 index 00000000000..f61e9c66672 --- /dev/null +++ b/packages/react-core/src/next/components/Dropdown/Dropdown.tsx @@ -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) => React.ReactNode; + /** Flag to indicate if menu is opened.*/ + isOpen?: boolean; + /** Function callback called when user selects item. */ + onSelect?: (event?: React.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 = ({ + children, + className, + onSelect, + isOpen, + toggle, + onOpenChange, + isPlain, + isScrollable, + minWidth, + ...props +}: DropdownProps) => { + const localMenuRef = React.useRef(); + const toggleRef = React.useRef(); + const containerRef = React.useRef(); + + const menuRef = (props.innerRef as React.RefObject) || 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 = ( + onSelect(event, itemId)} + isPlain={isPlain} + isScrollable={isScrollable} + {...(minWidth && { + style: { + '--pf-c-menu--MinWidth': minWidth + } as React.CSSProperties + })} + {...props} + > + {children} + + ); + return ( +
+ +
+ ); +}; +Dropdown.displayName = 'Dropdown'; diff --git a/packages/react-core/src/next/components/Dropdown/DropdownGroup.tsx b/packages/react-core/src/next/components/Dropdown/DropdownGroup.tsx new file mode 100644 index 00000000000..e39e60cb925 --- /dev/null +++ b/packages/react-core/src/next/components/Dropdown/DropdownGroup.tsx @@ -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 { + /** 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 = ({ + children, + className, + label, + labelHeadingLevel = 'h1', + ...props +}: DropdownGroupProps) => ( + + {children} + +); +DropdownGroup.displayName = 'DropdownGroup'; diff --git a/packages/react-core/src/next/components/Dropdown/DropdownItem.tsx b/packages/react-core/src/next/components/Dropdown/DropdownItem.tsx new file mode 100644 index 00000000000..a070c9651f3 --- /dev/null +++ b/packages/react-core/src/next/components/Dropdown/DropdownItem.tsx @@ -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 { + /** 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 = ({ + children, + className, + description, + ...props +}: DropdownItemProps) => ( + + {children} + +); +DropdownItem.displayName = 'DropdownItem'; diff --git a/packages/react-core/src/next/components/Dropdown/DropdownList.tsx b/packages/react-core/src/next/components/Dropdown/DropdownList.tsx new file mode 100644 index 00000000000..d4e46338346 --- /dev/null +++ b/packages/react-core/src/next/components/Dropdown/DropdownList.tsx @@ -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 = ({ + children, + className, + ...props +}: DropdownListProps) => ( + + {children} + +); +DropdownList.displayName = 'DropdownList'; diff --git a/packages/react-core/src/next/components/Dropdown/examples/Dropdown.md b/packages/react-core/src/next/components/Dropdown/examples/Dropdown.md new file mode 100644 index 00000000000..08b06814ffc --- /dev/null +++ b/packages/react-core/src/next/components/Dropdown/examples/Dropdown.md @@ -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" +``` diff --git a/packages/react-core/src/next/components/Dropdown/examples/DropdownBasic.tsx b/packages/react-core/src/next/components/Dropdown/examples/DropdownBasic.tsx new file mode 100644 index 00000000000..4627344197f --- /dev/null +++ b/packages/react-core/src/next/components/Dropdown/examples/DropdownBasic.tsx @@ -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(null); + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const onSelect = (_event: React.MouseEvent | undefined, itemId: string | number | undefined) => { + // eslint-disable-next-line no-console + console.log('selected', itemId); + setIsOpen(false); + }; + + return ( + setIsOpen(isOpen)} + toggle={toggleRef => ( + + Dropdown + + )} + > + + + Link + + ev.preventDefault()}> + Action + + + Disabled link + + + Disabled action + + + + Separated link + + + Separated action + + + + ); +}; diff --git a/packages/react-core/src/next/components/Dropdown/examples/DropdownWithDescriptions.tsx b/packages/react-core/src/next/components/Dropdown/examples/DropdownWithDescriptions.tsx new file mode 100644 index 00000000000..e675975046b --- /dev/null +++ b/packages/react-core/src/next/components/Dropdown/examples/DropdownWithDescriptions.tsx @@ -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(null); + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const onSelect = (_event: React.MouseEvent | undefined, itemId: string | number | undefined) => { + // eslint-disable-next-line no-console + console.log('selected', itemId); + setIsOpen(false); + }; + + return ( + setIsOpen(isOpen)} + toggle={toggleRef => ( + + Dropdown + + )} + > + + + Link + + ev.preventDefault()} + > + Action + + + Disabled link + + + Disabled action + + + + ); +}; diff --git a/packages/react-core/src/next/components/Dropdown/examples/DropdownWithGroups.tsx b/packages/react-core/src/next/components/Dropdown/examples/DropdownWithGroups.tsx new file mode 100644 index 00000000000..241b451fe9a --- /dev/null +++ b/packages/react-core/src/next/components/Dropdown/examples/DropdownWithGroups.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Dropdown, DropdownGroup, DropdownItem, DropdownList } from '@patternfly/react-core/next'; +import { MenuToggle, Divider } from '@patternfly/react-core'; + +export const DropdownWithGroups: React.FunctionComponent = () => { + const [isOpen, setIsOpen] = React.useState(false); + const menuRef = React.useRef(null); + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const onSelect = (_event: React.MouseEvent | undefined, itemId: string | number | undefined) => { + // eslint-disable-next-line no-console + console.log('selected', itemId); + setIsOpen(false); + }; + + return ( + setIsOpen(isOpen)} + toggle={toggleRef => ( + + Dropdown + + )} + > + + + + Link + + ev.preventDefault()}> + Action + + + + + + + + Group 2 link + + + group 2 action + + + + + + + + Group 3 link + + + Group 3 action + + + + + ); +}; diff --git a/packages/react-core/src/next/components/Dropdown/examples/DropdownWithKebabToggle.tsx b/packages/react-core/src/next/components/Dropdown/examples/DropdownWithKebabToggle.tsx new file mode 100644 index 00000000000..22f0d584b40 --- /dev/null +++ b/packages/react-core/src/next/components/Dropdown/examples/DropdownWithKebabToggle.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { Dropdown, DropdownItem, DropdownList } from '@patternfly/react-core/next'; +import { Divider, MenuToggle } from '@patternfly/react-core'; +import EllipsisVIcon from '@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon'; + +export const DropdownWithKebab: React.FunctionComponent = () => { + const [isOpen, setIsOpen] = React.useState(false); + const menuRef = React.useRef(null); + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const onSelect = (_event: React.MouseEvent | undefined, itemId: string | number | undefined) => { + // eslint-disable-next-line no-console + console.log('selected', itemId); + setIsOpen(false); + }; + + return ( + setIsOpen(isOpen)} + toggle={toggleRef => ( + + + + )} + > + + + Link + + ev.preventDefault()}> + Action + + + Disabled link + + + Disabled action + + , + + Separated link + + + Separated action + + + + ); +}; diff --git a/packages/react-core/src/next/components/Dropdown/index.ts b/packages/react-core/src/next/components/Dropdown/index.ts new file mode 100644 index 00000000000..0edc20d91ad --- /dev/null +++ b/packages/react-core/src/next/components/Dropdown/index.ts @@ -0,0 +1,4 @@ +export * from './Dropdown'; +export * from './DropdownGroup'; +export * from './DropdownItem'; +export * from './DropdownList'; diff --git a/packages/react-core/src/next/components/index.ts b/packages/react-core/src/next/components/index.ts index 4ad68566220..802328b87c6 100644 --- a/packages/react-core/src/next/components/index.ts +++ b/packages/react-core/src/next/components/index.ts @@ -1 +1,2 @@ export * from './Wizard'; +export * from './Dropdown';