diff --git a/packages/react-core/src/components/Dropdown/Dropdown.tsx b/packages/react-core/src/components/Dropdown/Dropdown.tsx index f3d2b3b4d4a..74f139b8d01 100644 --- a/packages/react-core/src/components/Dropdown/Dropdown.tsx +++ b/packages/react-core/src/components/Dropdown/Dropdown.tsx @@ -43,8 +43,10 @@ export interface DropdownProps extends MenuProps, OUIAProps { /** 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. */ + * Triggered by clicking outside of the menu, or by pressing any keys specificed in onOpenChangeKeys. */ onOpenChange?: (isOpen: boolean) => void; + /** @beta Keys that trigger onOpenChange, defaults to tab and escape. It is highly recommended to include Escape in the array, while Tab may be omitted if the menu contains non-menu items that are focusable. */ + onOpenChangeKeys?: string[]; /** Indicates if the menu should be without the outer box-shadow. */ isPlain?: boolean; /** Indicates if the menu should be scrollable. */ @@ -76,6 +78,7 @@ const DropdownBase: React.FunctionComponent = ({ ouiaSafe = true, zIndex = 9999, popperProps, + onOpenChangeKeys = ['Escape', 'Tab'], ...props }: DropdownProps) => { const localMenuRef = React.useRef(); @@ -96,8 +99,7 @@ const DropdownBase: React.FunctionComponent = ({ onOpenChange && (menuRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)) ) { - if (event.key === 'Escape' || event.key === 'Tab') { - event.preventDefault(); + if (onOpenChangeKeys.includes(event.key)) { onOpenChange(false); toggleRef.current?.focus(); } @@ -130,7 +132,7 @@ const DropdownBase: React.FunctionComponent = ({ window.removeEventListener('keydown', handleMenuKeys); window.removeEventListener('click', handleClick); }; - }, [isOpen, menuRef, toggleRef, onOpenChange]); + }, [isOpen, menuRef, toggleRef, onOpenChange, onOpenChangeKeys]); const menu = ( { (document.activeElement.closest('ol') && document.activeElement.closest('ol').firstChild === element) } getFocusableElement={(navigableElement: Element) => - (navigableElement.tagName === 'DIV' && navigableElement.querySelector('input')) || // for MenuSearchInput - ((navigableElement.firstChild as Element).tagName === 'LABEL' && + (navigableElement?.tagName === 'DIV' && navigableElement.querySelector('input')) || // for MenuSearchInput + ((navigableElement.firstChild as Element)?.tagName === 'LABEL' && navigableElement.querySelector('input')) || // for MenuItem checkboxes (navigableElement.firstChild as Element) } diff --git a/packages/react-core/src/components/Menu/MenuContainer.tsx b/packages/react-core/src/components/Menu/MenuContainer.tsx new file mode 100644 index 00000000000..16f002561f3 --- /dev/null +++ b/packages/react-core/src/components/Menu/MenuContainer.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { Popper } from '../../helpers/Popper/Popper'; + +export interface MenuPopperProps { + /** Vertical direction of the popper. If enableFlip is set to true, this will set the initial direction before the popper flips. */ + direction?: 'up' | 'down'; + /** Horizontal position of the popper */ + position?: 'right' | 'left' | 'center'; + /** Custom width of the popper. If the value is "trigger", it will set the width to the dropdown toggle's width */ + width?: string | 'trigger'; + /** Minimum width of the popper. If the value is "trigger", it will set the min width to the dropdown toggle's width */ + minWidth?: string | 'trigger'; + /** Maximum width of the popper. If the value is "trigger", it will set the max width to the dropdown toggle's width */ + maxWidth?: string | 'trigger'; + /** Enable to flip the popper when it reaches the boundary */ + enableFlip?: boolean; +} +export interface MenuContainerProps { + /** Menu to be rendered */ + menu: React.ReactElement>; + /** Reference to the menu */ + menuRef: React.RefObject; + /** Toggle to be rendered */ + toggle: React.ReactNode; + /** Reference to the toggle */ + toggleRef: React.RefObject; + /** Flag to indicate if menu is opened.*/ + isOpen: boolean; + /** Callback to change the open state of the menu. + * Triggered by clicking outside of the menu, or by pressing any keys specificed in onOpenChangeKeys. */ + onOpenChange?: (isOpen: boolean) => void; + /** Keys that trigger onOpenChange, defaults to tab and escape. It is highly recommended to include Escape in the array, while Tab may be omitted if the menu contains non-menu items that are focusable. */ + onOpenChangeKeys?: string[]; + /** z-index of the dropdown menu */ + zIndex?: number; + /** Additional properties to pass to the Popper */ + popperProps?: MenuPopperProps; +} + +/** + * Container that links a menu and menu toggle together, to handle basic keyboard input and control the opening and closing of a menu. + * This component is currently in beta and is subject to change. + */ +export const MenuContainer: React.FunctionComponent = ({ + menu, + menuRef, + isOpen, + toggle, + toggleRef, + onOpenChange, + zIndex = 9999, + popperProps, + onOpenChangeKeys = ['Escape', 'Tab'] +}: MenuContainerProps) => { + React.useEffect(() => { + const handleMenuKeys = (event: KeyboardEvent) => { + // 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 (onOpenChangeKeys.includes(event.key)) { + onOpenChange(false); + toggleRef.current?.focus(); + } + } + }; + + const handleClick = (event: MouseEvent) => { + // toggle was clicked open via keyboard, focus on first menu item + if (isOpen && toggleRef.current?.contains(event.target as Node) && event.detail === 0) { + setTimeout(() => { + const firstElement = menuRef?.current?.querySelector( + 'li button:not(:disabled),li input:not(:disabled),li a:not([aria-disabled="true"])' + ); + firstElement && (firstElement as HTMLElement).focus(); + }, 0); + } + + // 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', handleClick); + + return () => { + window.removeEventListener('keydown', handleMenuKeys); + window.removeEventListener('click', handleClick); + }; + }, [isOpen, menuRef, onOpenChange, onOpenChangeKeys, toggleRef]); + + return ( + + ); +}; +MenuContainer.displayName = 'MenuContainer'; diff --git a/packages/react-core/src/components/Menu/examples/Menu.md b/packages/react-core/src/components/Menu/examples/Menu.md index 2686b81a06c..6b309cbb60e 100644 --- a/packages/react-core/src/components/Menu/examples/Menu.md +++ b/packages/react-core/src/components/Menu/examples/Menu.md @@ -3,7 +3,19 @@ id: Menu section: components subsection: menus cssPrefix: pf-c-menu -propComponents: ['Menu', 'MenuList', 'MenuItem', 'MenuItemAction', 'MenuContent', 'MenuSearch', 'MenuSearchInput', 'MenuGroup'] +propComponents: + [ + 'Menu', + 'MenuList', + 'MenuItem', + 'MenuItemAction', + 'MenuContent', + 'MenuSearch', + 'MenuSearchInput', + 'MenuGroup', + 'MenuContainer', + 'MenuPopperProps' + ] ouia: true --- diff --git a/packages/react-core/src/components/Menu/index.ts b/packages/react-core/src/components/Menu/index.ts index f2958c9e1b1..ca9649ff577 100644 --- a/packages/react-core/src/components/Menu/index.ts +++ b/packages/react-core/src/components/Menu/index.ts @@ -9,3 +9,4 @@ export * from './MenuList'; export * from './MenuItemAction'; export * from './DrilldownMenu'; export * from './MenuBreadcrumb'; +export * from './MenuContainer'; diff --git a/packages/react-core/src/demos/ComposableMenu/ComposableMenu.md b/packages/react-core/src/demos/ComposableMenu/ComposableMenu.md deleted file mode 100644 index b221de57461..00000000000 --- a/packages/react-core/src/demos/ComposableMenu/ComposableMenu.md +++ /dev/null @@ -1,118 +0,0 @@ ---- -id: Custom menus -section: components -subsection: menus ---- - -import { Link } from '@reach/router'; - -import CogIcon from '@patternfly/react-icons/dist/esm/icons/cog-icon'; -import EllipsisVIcon from '@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon'; -import TableIcon from '@patternfly/react-icons/dist/esm/icons/table-icon'; -import StorageDomainIcon from '@patternfly/react-icons/dist/esm/icons/storage-domain-icon'; -import CodeBranchIcon from '@patternfly/react-icons/dist/esm/icons/code-branch-icon'; -import LayerGroupIcon from '@patternfly/react-icons/dist/esm/icons/layer-group-icon'; -import CubeIcon from '@patternfly/react-icons/dist/esm/icons/cube-icon'; -import BarsIcon from '@patternfly/react-icons/dist/esm/icons/bars-icon'; -import ClipboardIcon from '@patternfly/react-icons/dist/esm/icons/clipboard-icon'; -import BellIcon from '@patternfly/react-icons/dist/esm/icons/bell-icon'; -import ThIcon from '@patternfly/react-icons/dist/esm/icons/th-icon'; -import pfIcon from './examples/pf-logo-small.svg'; -import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon'; -import CaretDownIcon from '@patternfly/react-icons/dist/esm/icons/caret-down-icon'; -import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; -import avatarImg from './examples/avatarImg.svg'; -import { css } from '@patternfly/react-styles'; -import styles from '@patternfly/react-styles/css/components/Menu/menu'; - -## Demos - -Custom menus can be constructed using a composable approach by combining the [Menu](/components/menus/menu) and [Menu toggle](/components/menus/menu-toggle) components in unique ways. Composable menus currently require consumer keyboard handling and use of our undocumented [popper.js](https://popper.js.org/) wrapper component called Popper. We understand this is inconvientent boilerplate and these examples will be updated to use [Dropdown](/components/dropdown) in a future release. - -### Composable simple dropdown - -```ts file="./examples/ComposableSimpleDropdown.tsx" - -``` - -### Composable actions menu - -```ts file="./examples/ComposableActionsMenu.tsx" - -``` - -### Composable simple select - -```ts file="./examples/ComposableSimpleSelect.tsx" - -``` - -### Composable simple checkbox select - -```ts file="./examples/ComposableSimpleCheckboxSelect.tsx" - -``` - -### Composable typeahead select - -```ts file="./examples/ComposableTypeaheadSelect.tsx" - -``` - -### Composable multiple typeahead select - -```ts file="./examples/ComposableMultipleTypeaheadSelect.tsx" - -``` - -### Composable drilldown menu - -```ts isBeta file="./examples/ComposableDrilldownMenu.tsx" - -``` - -### Composable tree view menu - -When rendering a menu-like element that does not contain MenuItem components, [Panel](/components/panel) allows more flexible control and customization. - -```ts file="./examples/ComposableTreeViewMenu.tsx" - -``` - -### Composable flyout - -The flyout will automatically position to the left or top if it would otherwise go outside the window. The menu must be placed in a container outside the main content like Popper, [Popover](/components/popover) or [Tooltip](/components/tooltip) since it may go over the side nav. - -```ts isBeta file="./examples/ComposableFlyout.tsx" - -``` - -### Composable application launcher - -```ts file="./examples/ComposableApplicationLauncher.tsx" - -``` - -### Composable context selector - -```ts file="./examples/ComposableContextSelector.tsx" - -``` - -### Composable options menu variants - -```ts file="./examples/ComposableOptionsMenuVariants.tsx" - -``` - -### Composable dropdown variants - -```ts file="./examples/ComposableDropdwnVariants.tsx" - -``` - -### Composable date select - -```ts file="./examples/ComposableDateSelect.tsx" - -``` diff --git a/packages/react-core/src/demos/ComposableMenu/examples/ComposableMultipleTypeaheadSelect.tsx b/packages/react-core/src/demos/ComposableMenu/examples/ComposableMultipleTypeaheadSelect.tsx deleted file mode 100644 index 4dc8503783c..00000000000 --- a/packages/react-core/src/demos/ComposableMenu/examples/ComposableMultipleTypeaheadSelect.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import React from 'react'; -import { - Menu, - MenuContent, - MenuList, - MenuItem, - MenuItemProps, - MenuToggle, - MenuToggleElement, - Popper, - TextInputGroup, - TextInputGroupMain, - TextInputGroupUtilities, - ChipGroup, - Chip, - Button -} from '@patternfly/react-core'; -import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; - -const intitalMenuItems = [ - { itemId: 'Option 1', children: 'Option 1' }, - { itemId: 'Option 2', children: 'Option 2' }, - { itemId: 'Option 3', children: 'Option 3' } -]; - -export const ComposableMultipleTypeaheadSelect: React.FunctionComponent = () => { - const [isMenuOpen, setIsMenuOpen] = React.useState(false); - const [inputValue, setInputValue] = React.useState(''); - const [menuItems, setMenuItems] = React.useState(intitalMenuItems); - const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); - const [activeItem, setActiveItem] = React.useState(null); - const [selected, setSelected] = React.useState([]); - const menuToggleRef = React.useRef({} as MenuToggleElement); - const textInputRef = React.useRef(); - const menuRef = React.useRef(); - const toggleRef = React.useRef(null); - - React.useEffect(() => { - let newMenuItems: MenuItemProps[] = intitalMenuItems; - - // Filter menu items based on the text input value when one exists - if (inputValue) { - newMenuItems = intitalMenuItems.filter((menuItem) => - String(menuItem.children).toLowerCase().includes(inputValue.toLowerCase()) - ); - - // When no options are found after filtering, display 'No results found'. - if (!newMenuItems.length) { - newMenuItems = [{ isDisabled: false, children: `No results found for "${inputValue}"`, itemId: 'no results' }]; - } - - // Open the menu when the input value changes and the new value is not empty - if (!isMenuOpen) { - setIsMenuOpen(true); - } - } - - setMenuItems(newMenuItems); - setActiveItem(null); - setFocusedItemIndex(null); - }, [inputValue]); - - const focusOnInput = () => textInputRef.current?.focus(); - - const handleMenuArrowKeys = (key: string) => { - let indexToFocus; - - if (isMenuOpen) { - if (key === 'ArrowUp') { - // When no index is set or at the first index, focus to the last, otherwise decrement focus index - if (focusedItemIndex === null || focusedItemIndex === 0) { - indexToFocus = menuItems.length - 1; - } else { - indexToFocus = focusedItemIndex - 1; - } - } - - if (key === 'ArrowDown') { - // When no index is set or at the last index, focus to the first, otherwise increment focus index - if (focusedItemIndex === null || focusedItemIndex === menuItems.length - 1) { - indexToFocus = 0; - } else { - indexToFocus = focusedItemIndex + 1; - } - } - - setFocusedItemIndex(indexToFocus); - const focusedItem = menuItems.filter((item) => !item.isDisabled)[indexToFocus]; - setActiveItem(`composable-multi-typeahead-${focusedItem.itemId.replace(' ', '-')}`); - } - }; - - const onInputKeyDown = (event: React.KeyboardEvent) => { - const enabledMenuItems = menuItems.filter((menuItem) => !menuItem.isDisabled); - const [firstMenuItem] = enabledMenuItems; - const focusedItem = focusedItemIndex ? enabledMenuItems[focusedItemIndex] : firstMenuItem; - - switch (event.key) { - // Select the first available option - case 'Enter': - if (!isMenuOpen) { - setIsMenuOpen((prevIsOpen) => !prevIsOpen); - // Only allow selection if the first item is a valid, selectable option - } else if (isMenuOpen && focusedItem.itemId !== 'no results') { - onMenuSelect(focusedItem.itemId as string); - } - break; - case 'Tab': - case 'Escape': - setIsMenuOpen(false); - setActiveItem(null); - break; - case 'ArrowUp': - case 'ArrowDown': - event.preventDefault(); - handleMenuArrowKeys(event.key); - break; - } - }; - - // Close the menu when a click occurs outside of the menu, toggle, or input. - const onDocumentClick = (event: MouseEvent | undefined) => { - const isValidClick = [menuRef, menuToggleRef, textInputRef].some((ref) => - ref?.current?.contains(event?.target as HTMLElement) - ); - if (isMenuOpen && !isValidClick) { - setIsMenuOpen(false); - setActiveItem(null); - } - }; - - // Close the menu when focus is on a menu item and Escape or Tab is pressed - const onDocumentKeydown = (event: KeyboardEvent | undefined) => { - if (isMenuOpen && menuRef?.current?.contains(event?.target as HTMLElement)) { - if (event?.key === 'Escape') { - setIsMenuOpen(false); - focusOnInput(); - } else if (event?.key === 'Tab') { - setIsMenuOpen(false); - } - } - }; - - const toggleMenuOpen = () => { - setIsMenuOpen(!isMenuOpen); - focusOnInput(); - }; - - const onTextInputChange = (_event: React.FormEvent, value: string) => { - setInputValue(value); - }; - - const onMenuSelect = (itemId: string) => { - // Only allow selection if the item is a valid, selectable option - if (itemId && itemId !== 'no results') { - setSelected( - selected.includes(itemId) ? selected.filter((selection) => selection !== itemId) : [...selected, itemId] - ); - } - - focusOnInput(); - }; - - const toggle = ( - - - - - {selected.map((selection, index) => ( - { - ev.stopPropagation(); - onMenuSelect(selection); - }} - > - {selection} - - ))} - - - - {selected.length > 0 && ( - - )} - - - - ); - const menu = ( - } - id="multiple-typeahead-select-menu" - onSelect={(_ev, itemId) => onMenuSelect(itemId?.toString() as string)} - selected={selected} - role="listbox" - > - - - {menuItems.map((itemProps, index) => ( - - ))} - - - - ); - return ( - - ); -}; diff --git a/packages/react-core/src/demos/ComposableMenu/examples/ComposableOptionsMenuVariants.tsx b/packages/react-core/src/demos/ComposableMenu/examples/ComposableOptionsMenuVariants.tsx deleted file mode 100644 index bc9a1a084e8..00000000000 --- a/packages/react-core/src/demos/ComposableMenu/examples/ComposableOptionsMenuVariants.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import React from 'react'; -import { MenuToggle, Menu, MenuContent, MenuList, MenuItem, MenuGroup, Popper, Divider } from '@patternfly/react-core'; - -export const ComposableOptionsMenuVariants: React.FunctionComponent = () => { - const [isOpen, setIsOpen] = React.useState(false); - const [selected, setSelected] = React.useState(''); - const menuRef = React.useRef(); - const toggleRef = React.useRef(); - - const handleMenuKeys = (event: KeyboardEvent) => { - if (isOpen && menuRef?.current?.contains(event.target as Node)) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsOpen(!isOpen); - toggleRef?.current?.focus(); - } - } - }; - - const handleClickOutside = (event: MouseEvent) => { - if (isOpen && !menuRef?.current?.contains(event.target as Node)) { - setIsOpen(false); - } - }; - - React.useEffect(() => { - window.addEventListener('keydown', handleMenuKeys); - window.addEventListener('click', handleClickOutside); - - return () => { - window.removeEventListener('keydown', handleMenuKeys); - window.removeEventListener('click', handleClickOutside); - }; - }, [isOpen, menuRef]); - - const onToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); // Stop handleClickOutside from handling - setTimeout(() => { - if (menuRef.current) { - const firstElement = menuRef.current.querySelector( - 'li > button:not(:disabled), li > a:not(:disabled), input:not(:disabled)' - ); - firstElement && (firstElement as HTMLElement).focus(); - } - }, 0); - setIsOpen(!isOpen); - }; - - const toggle = ( - - Options menu - - ); - - const menu = ( - setSelected(itemId.toString())} - > - - - - Option 1 - - - Disabled Option - - - - - - Option 1 - - - Option 2 - - - - - - - - Option 1 - - - Option 2 - - - - - - - ); - return ; -}; diff --git a/packages/react-core/src/demos/ComposableMenu/examples/ComposableSimpleCheckboxSelect.tsx b/packages/react-core/src/demos/ComposableMenu/examples/ComposableSimpleCheckboxSelect.tsx deleted file mode 100644 index 61ea2852880..00000000000 --- a/packages/react-core/src/demos/ComposableMenu/examples/ComposableSimpleCheckboxSelect.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import React from 'react'; -import { MenuToggle, Menu, MenuContent, MenuList, MenuItem, Popper, Badge } from '@patternfly/react-core'; - -export const ComposableSimpleCheckboxSelect: React.FunctionComponent = () => { - const [isOpen, setIsOpen] = React.useState(false); - const [selectedItems, setSelectedItems] = React.useState([]); - const toggleRef = React.useRef(null); - const menuRef = React.useRef(null); - - const handleMenuKeys = React.useCallback( - (event) => { - if (menuRef.current) { - if (isOpen && menuRef.current.contains(event.target as Node)) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsOpen(!isOpen); - toggleRef?.current?.focus(); - } - } - } - }, - [isOpen] - ); - - const handleClickOutside = React.useCallback( - (event) => { - if (isOpen && !menuRef?.current?.contains(event.target as Node)) { - setIsOpen(false); - } - }, - [isOpen] - ); - - React.useEffect(() => { - window.addEventListener('keydown', handleMenuKeys); - window.addEventListener('click', handleClickOutside); - return () => { - window.removeEventListener('keydown', handleMenuKeys); - window.removeEventListener('click', handleClickOutside); - }; - }, [handleClickOutside, handleMenuKeys]); - - const onToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); // Stop handleClickOutside from handling - setTimeout(() => { - if (menuRef.current) { - const firstElement = menuRef.current.querySelector('li > button:not(:disabled), li > a:not(:disabled)'); - firstElement && (firstElement as HTMLElement).focus(); - } - }, 0); - setIsOpen(!isOpen); - }; - - const onSelect = (event: React.MouseEvent | undefined, itemId: string | number | undefined) => { - if (typeof itemId === 'string' || typeof itemId === 'undefined') { - return; - } - - if (selectedItems.includes(itemId)) { - setSelectedItems(selectedItems.filter((id) => id !== itemId)); - } else { - setSelectedItems([...selectedItems, itemId]); - } - }; - - const toggle = ( - 0 && { badge: {selectedItems.length} })} - onClick={onToggleClick} - isExpanded={isOpen} - style={ - { - width: '220px' - } as React.CSSProperties - } - > - Filter by status - - ); - const menu = ( - - - - - Debug - - - Info - - - Warn - - - Error - - - - - ); - return ; -}; diff --git a/packages/react-core/src/demos/ComposableMenu/examples/ComposableSimpleDropdown.tsx b/packages/react-core/src/demos/ComposableMenu/examples/ComposableSimpleDropdown.tsx deleted file mode 100644 index 98d446418e3..00000000000 --- a/packages/react-core/src/demos/ComposableMenu/examples/ComposableSimpleDropdown.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from 'react'; -import { MenuToggle, Menu, MenuContent, MenuList, MenuItem, Popper } from '@patternfly/react-core'; - -export const ComposableSimpleDropdown: React.FunctionComponent = () => { - const [isOpen, setIsOpen] = React.useState(false); - const toggleRef = React.useRef(null); - const menuRef = React.useRef(null); - - const handleMenuKeys = (event: KeyboardEvent) => { - if (!isOpen) { - return; - } - if (menuRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsOpen(!isOpen); - toggleRef.current?.focus(); - } - } - }; - - const handleClickOutside = (event: MouseEvent) => { - if (isOpen && !menuRef.current?.contains(event.target as Node)) { - setIsOpen(false); - } - }; - - React.useEffect(() => { - window.addEventListener('keydown', handleMenuKeys); - window.addEventListener('click', handleClickOutside); - return () => { - window.removeEventListener('keydown', handleMenuKeys); - window.removeEventListener('click', handleClickOutside); - }; - }, [isOpen, menuRef]); - - const onToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); // Stop handleClickOutside from handling - setTimeout(() => { - if (menuRef.current) { - const firstElement = menuRef.current.querySelector('li > button:not(:disabled), li > a:not(:disabled)'); - firstElement && (firstElement as HTMLElement).focus(); - } - }, 0); - setIsOpen(!isOpen); - }; - - const toggle = ( - - {isOpen ? 'Expanded' : 'Collapsed'} - - ); - const menu = ( - // eslint-disable-next-line no-console - console.log('selected', itemId)}> - - - Action - ev.preventDefault()}> - Link - - Disabled Action - - Disabled Link - - - - - ); - return ; -}; diff --git a/packages/react-core/src/demos/ComposableMenu/examples/ComposableSimpleSelect.tsx b/packages/react-core/src/demos/ComposableMenu/examples/ComposableSimpleSelect.tsx deleted file mode 100644 index e17561a0f7a..00000000000 --- a/packages/react-core/src/demos/ComposableMenu/examples/ComposableSimpleSelect.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import React from 'react'; -import { MenuToggle, Menu, MenuContent, MenuList, MenuItem, Popper } from '@patternfly/react-core'; -import TableIcon from '@patternfly/react-icons/dist/esm/icons/table-icon'; - -export const ComposableSimpleSelect: React.FunctionComponent = () => { - const [isOpen, setIsOpen] = React.useState(false); - const [selected, setSelected] = React.useState('Select a value'); - const toggleRef = React.useRef(null); - const menuRef = React.useRef(null); - - const handleMenuKeys = (event: KeyboardEvent) => { - if (isOpen && menuRef.current?.contains(event.target as Node)) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsOpen(!isOpen); - toggleRef.current?.focus(); - } - } - }; - - const handleClickOutside = (event: MouseEvent) => { - if (isOpen && !menuRef.current?.contains(event.target as Node)) { - setIsOpen(false); - } - }; - - React.useEffect(() => { - window.addEventListener('keydown', handleMenuKeys); - window.addEventListener('click', handleClickOutside); - return () => { - window.removeEventListener('keydown', handleMenuKeys); - window.removeEventListener('click', handleClickOutside); - }; - }, [isOpen, menuRef]); - - const onToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); // Stop handleClickOutside from handling - setTimeout(() => { - if (menuRef.current) { - const firstElement = menuRef.current.querySelector('li > button:not(:disabled), li > a:not(:disabled)'); - firstElement && (firstElement as HTMLElement).focus(); - } - }, 0); - setIsOpen(!isOpen); - }; - - const toggle = ( - - {selected} - - ); - - function onSelect(event: React.MouseEvent | undefined, itemId: string | number | undefined) { - if (typeof itemId === 'undefined') { - return; - } - - setSelected(itemId.toString()); - } - - const menu = ( - - - - Option 1 - Option 2 - }> - Option 3 - - - - - ); - return ; -}; diff --git a/packages/react-core/src/demos/ComposableMenu/examples/ComposableTypeaheadSelect.tsx b/packages/react-core/src/demos/ComposableMenu/examples/ComposableTypeaheadSelect.tsx deleted file mode 100644 index d57c232d70d..00000000000 --- a/packages/react-core/src/demos/ComposableMenu/examples/ComposableTypeaheadSelect.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import React from 'react'; - -import { - Menu, - MenuContent, - MenuList, - MenuItem, - Popper, - MenuToggle, - TextInputGroup, - MenuItemProps, - Button, - TextInputGroupUtilities, - TextInputGroupMain, - MenuToggleElement -} from '@patternfly/react-core'; -import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; -import TableIcon from '@patternfly/react-icons/dist/esm/icons/table-icon'; - -const intitalMenuItems: MenuItemProps[] = [ - { itemId: 'Option 1', children: 'Option 1' }, - { itemId: 'Option 2', children: 'Option 2' }, - { itemId: 'Option 3', children: 'Option 3', icon: } -]; - -export const ComposableTypeaheadSelect: React.FunctionComponent = () => { - const [isMenuOpen, setIsMenuOpen] = React.useState(false); - const [inputValue, setInputValue] = React.useState(''); - const [menuItems, setMenuItems] = React.useState(intitalMenuItems); - const [focusedItemIndex, setFocusedItemIndex] = React.useState(null); - const [activeItem, setActiveItem] = React.useState(null); - const [isSelected, setIsSelected] = React.useState(false); - - const menuToggleRef = React.useRef({} as MenuToggleElement); - const textInputRef = React.useRef(); - const menuRef = React.useRef(null); - const triggerRef = React.useRef(null); - - React.useEffect(() => { - let newMenuItems: MenuItemProps[] = intitalMenuItems; - - // Filter menu items based on the text input value when one exists - if (inputValue) { - newMenuItems = intitalMenuItems.filter((menuItem) => - String(menuItem.children).toLowerCase().includes(inputValue.toLowerCase()) - ); - - // When no options are found after filtering, display 'No results found' - if (!newMenuItems.length) { - newMenuItems = [{ isDisabled: false, children: `No results found for "${inputValue}"`, itemId: 'no results' }]; - } - - // Open the menu when the input value changes and the new value is not empty - if (!isMenuOpen) { - setIsMenuOpen(true); - } - } - - setMenuItems(newMenuItems); - setActiveItem(null); - setFocusedItemIndex(null); - }, [inputValue]); - - const focusOnInput = () => textInputRef.current?.focus(); - - const onMenuSelect = (_event: React.MouseEvent | undefined, itemId: string | number | undefined) => { - // Only allow selection if the item is a valid, selectable option - if (itemId && itemId !== 'no results') { - setInputValue(itemId.toString()); - setIsSelected(true); - } - - setIsMenuOpen(false); - setFocusedItemIndex(null); - setActiveItem(null); - focusOnInput(); - }; - - const handleMenuArrowKeys = (key: string) => { - let indexToFocus; - - if (isMenuOpen) { - if (key === 'ArrowUp') { - // When no index is set or at the first index, focus to the last, otherwise decrement focus index - if (focusedItemIndex === null || focusedItemIndex === 0) { - indexToFocus = menuItems.length - 1; - } else { - indexToFocus = focusedItemIndex - 1; - } - } - - if (key === 'ArrowDown') { - // When no index is set or at the last index, focus to the first, otherwise increment focus index - if (focusedItemIndex === null || focusedItemIndex === menuItems.length - 1) { - indexToFocus = 0; - } else { - indexToFocus = focusedItemIndex + 1; - } - } - - setFocusedItemIndex(indexToFocus); - const focusedItem = menuItems.filter((item) => !item.isDisabled)[indexToFocus]; - setActiveItem(`composable-typeahead-${focusedItem.itemId.replace(' ', '-')}`); - } - }; - - const onInputKeyDown = (event: React.KeyboardEvent) => { - const enabledMenuItems = menuItems.filter((menuItem) => !menuItem.isDisabled); - const [firstMenuItem] = enabledMenuItems; - const focusedItem = focusedItemIndex ? enabledMenuItems[focusedItemIndex] : firstMenuItem; - - switch (event.key) { - // Select the first available option - case 'Enter': - // Only allow selection if the first item is a valid, selectable option - if (isMenuOpen && focusedItem.itemId !== 'no results') { - setInputValue(String(focusedItem.children)); - setIsSelected(true); - } - - setIsMenuOpen((prevIsOpen) => !prevIsOpen); - setFocusedItemIndex(null); - setActiveItem(null); - focusOnInput(); - - break; - case 'Tab': - case 'Escape': - setIsMenuOpen(false); - setActiveItem(null); - break; - case 'ArrowUp': - case 'ArrowDown': - event.preventDefault(); - handleMenuArrowKeys(event.key); - break; - } - }; - - // Close the menu when a click occurs outside of the menu, toggle, or input - const onDocumentClick = (event: MouseEvent | undefined) => { - const isValidClick = [menuRef, menuToggleRef, textInputRef].some((ref) => - ref?.current?.contains(event?.target as HTMLElement) - ); - if (isMenuOpen && !isValidClick) { - setIsMenuOpen(false); - setActiveItem(null); - } - }; - - // Close the menu when focus is on a menu item and Escape or Tab is pressed - const onDocumentKeydown = (event: KeyboardEvent | undefined) => { - if (isMenuOpen && menuRef?.current?.contains(event?.target as HTMLElement)) { - if (event?.key === 'Escape') { - setIsMenuOpen(false); - focusOnInput(); - } else if (event?.key === 'Tab') { - setIsMenuOpen(false); - } - } - }; - - const toggleMenuOpen = () => { - setIsMenuOpen((prevIsOpen) => !prevIsOpen); - textInputRef.current?.focus(); - }; - - const onTextInputChange = (_event: React.FormEvent, value: string) => { - setInputValue(value); - setIsSelected(false); - }; - - return ( - - - - - - {!!inputValue && ( - - )} - - - - } - triggerRef={triggerRef} - popper={ - - - - {menuItems.map((itemProps, index) => ( - - ))} - - - - } - popperRef={menuRef} - isVisible={isMenuOpen} - onDocumentClick={onDocumentClick} - onDocumentKeyDown={onDocumentKeydown} - /> - ); -}; diff --git a/packages/react-core/src/demos/CustomMenus/ApplicationLauncher.md b/packages/react-core/src/demos/CustomMenus/ApplicationLauncher.md new file mode 100644 index 00000000000..252a0348a88 --- /dev/null +++ b/packages/react-core/src/demos/CustomMenus/ApplicationLauncher.md @@ -0,0 +1,30 @@ +--- +id: Application launcher +section: components +subsection: menus +source: react-demos +propComponents: + [ + 'MenuToggle', + 'MenuSearch', + 'MenuSearchInput', + 'Tooltip', + 'Divider', + 'SearchInput', + 'Dropdown', + 'DropdownGroup', + 'DropdownList', + 'DropdownItem' + ] +--- + +import ThIcon from '@patternfly/react-icons/dist/esm/icons/th-icon'; +import pfIcon from './examples/pf-logo-small.svg'; + +As the application launcher component is now deprecated, an application launcher may now be built using the new suite of menu components. This is showcased in the following demo, which uses the new [dropdown](/components/menus/dropdown) component that is built off of menu. + +### Application launcher menu + +```ts file="./examples/ApplicationLauncherDemo.tsx" + +``` diff --git a/packages/react-core/src/demos/CustomMenus/ContextSelector.md b/packages/react-core/src/demos/CustomMenus/ContextSelector.md new file mode 100644 index 00000000000..2f3fb252a98 --- /dev/null +++ b/packages/react-core/src/demos/CustomMenus/ContextSelector.md @@ -0,0 +1,30 @@ +--- +id: Context selector +section: components +subsection: menus +source: react-demos +propComponents: + [ + 'MenuToggle', + 'MenuFooter', + 'MenuSearch', + 'MenuSearchInput', + 'Divider', + 'InputGroup', + 'SearchInput', + 'Dropdown', + 'DropdownGroup', + 'DropdownList', + 'DropdownItem' + ] +--- + +import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon'; + +As the context selector component is now deprecated, a context selector may now be built using the new suite of menu components. This is showcased in the following demo, which uses the new [dropdown](/components/menus/dropdown) component that is built off of menu. + +### Context selector menu + +```ts file="./examples/ContextSelectorDemo.tsx" + +``` diff --git a/packages/react-core/src/demos/CustomMenus/CustomMenus.md b/packages/react-core/src/demos/CustomMenus/CustomMenus.md new file mode 100644 index 00000000000..03531f3b6af --- /dev/null +++ b/packages/react-core/src/demos/CustomMenus/CustomMenus.md @@ -0,0 +1,84 @@ +--- +id: Custom menus +section: components +subsection: menus +--- + +import { Link } from '@reach/router'; + +import CogIcon from '@patternfly/react-icons/dist/esm/icons/cog-icon'; +import EllipsisVIcon from '@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon'; +import TableIcon from '@patternfly/react-icons/dist/esm/icons/table-icon'; +import StorageDomainIcon from '@patternfly/react-icons/dist/esm/icons/storage-domain-icon'; +import CodeBranchIcon from '@patternfly/react-icons/dist/esm/icons/code-branch-icon'; +import LayerGroupIcon from '@patternfly/react-icons/dist/esm/icons/layer-group-icon'; +import CubeIcon from '@patternfly/react-icons/dist/esm/icons/cube-icon'; +import BarsIcon from '@patternfly/react-icons/dist/esm/icons/bars-icon'; +import ClipboardIcon from '@patternfly/react-icons/dist/esm/icons/clipboard-icon'; +import BellIcon from '@patternfly/react-icons/dist/esm/icons/bell-icon'; +import ThIcon from '@patternfly/react-icons/dist/esm/icons/th-icon'; +import pfIcon from './examples/pf-logo-small.svg'; +import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon'; +import CaretDownIcon from '@patternfly/react-icons/dist/esm/icons/caret-down-icon'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; +import avatarImg from './examples/avatarImg.svg'; +import { css } from '@patternfly/react-styles'; +import styles from '@patternfly/react-styles/css/components/Menu/menu'; + +## Examples + +Custom menus can be constructed using a composable approach by combining the [menu](/components/menus/menu) and [menu toggle](/components/menus/menu-toggle) components in unique ways. To handle basic keyboard inputs, [dropdown](/components/menus/dropdown), [select](/components/menus/select), or [``](/components/menus/menu#menucontainer) components may be used in combination with menu components. + +Additionally, menu components may be connected to each other manually through our undocumented internal [popper.js](https://popper.js.org/) wrapper component called Popper. + +### With actions + +```ts file="./examples/ActionsMenuDemo.tsx" + +``` + +### With favorites + +```ts file="./examples/FavoritesDemo.tsx" + +``` + +### With drilldown + +```ts isBeta file="./examples/DrilldownMenuDemo.tsx" + +``` + +### Tree view menu + +When rendering a menu-like element that does not contain `` components, [panel](/components/panel) allows more flexible control and customization. + +```ts file="./examples/TreeViewMenuDemo.tsx" + +``` + +### Flyout menu + +The flyout will automatically position to the left or top if it would otherwise go outside the window. The menu must be placed in a container outside the main content like Popper, [popover](/components/popover) or [tooltip](/components/tooltip), since it may go over the side nav. + +```ts isBeta file="./examples/FlyoutDemo.tsx" + +``` + +### Application launcher menu + +```ts file="./examples/ApplicationLauncherDemo.tsx" + +``` + +### Context selector menu + +```ts file="./examples/ContextSelectorDemo.tsx" + +``` + +### Date select menu + +```ts file="./examples/DateSelectDemo.tsx" + +``` diff --git a/packages/react-core/src/demos/CustomMenus/OptionsMenu.md b/packages/react-core/src/demos/CustomMenus/OptionsMenu.md new file mode 100644 index 00000000000..d68e28fd488 --- /dev/null +++ b/packages/react-core/src/demos/CustomMenus/OptionsMenu.md @@ -0,0 +1,17 @@ +--- +id: Options menu +section: components +subsection: menus +source: react-demos +propComponents: ['MenuToggle', 'Divider', 'Select', 'SelectList', 'SelectOption', 'SelectGroup'] +--- + +import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon'; + +As the `` component is now deprecated, an options menu may now be built using the new suite of menu components. This is showcased in the following demo, which uses the new [select](/components/menus/select) component that is built off of menu. + +### Options menu + +```ts file="./examples/OptionsMenuDemo.tsx" + +``` diff --git a/packages/react-core/src/demos/ComposableMenu/examples/ComposableActionsMenu.tsx b/packages/react-core/src/demos/CustomMenus/examples/ActionsMenuDemo.tsx similarity index 55% rename from packages/react-core/src/demos/ComposableMenu/examples/ComposableActionsMenu.tsx rename to packages/react-core/src/demos/CustomMenus/examples/ActionsMenuDemo.tsx index f942fc467f1..92f36364537 100644 --- a/packages/react-core/src/demos/ComposableMenu/examples/ComposableActionsMenu.tsx +++ b/packages/react-core/src/demos/CustomMenus/examples/ActionsMenuDemo.tsx @@ -1,40 +1,15 @@ import React from 'react'; -import { MenuToggle, Menu, MenuList, MenuItem, MenuGroup, MenuItemAction, Popper } from '@patternfly/react-core'; +import { MenuToggle, MenuItemAction, Select, SelectGroup, SelectList, SelectOption } from '@patternfly/react-core'; import BarsIcon from '@patternfly/react-icons/dist/esm/icons/bars-icon'; import ClipboardIcon from '@patternfly/react-icons/dist/esm/icons/clipboard-icon'; import CodeBranchIcon from '@patternfly/react-icons/dist/esm/icons/code-branch-icon'; import BellIcon from '@patternfly/react-icons/dist/esm/icons/bell-icon'; -export const ComposableActionsMenu: React.FunctionComponent = () => { +export const ActionsMenuDemo: React.FunctionComponent = () => { const [isOpen, setIsOpen] = React.useState(false); const [selectedItems, setSelectedItems] = React.useState([]); - const toggleRef = React.useRef(null); const menuRef = React.useRef(null); - const handleMenuKeys = (event: KeyboardEvent) => { - if (isOpen && menuRef.current?.contains(event.target as Node)) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsOpen(!isOpen); - toggleRef.current?.focus(); - } - } - }; - - const handleClickOutside = (event: MouseEvent) => { - if (isOpen && !menuRef.current?.contains(event.target as Node)) { - setIsOpen(false); - } - }; - - React.useEffect(() => { - window.addEventListener('keydown', handleMenuKeys); - window.addEventListener('click', handleClickOutside); - return () => { - window.removeEventListener('keydown', handleMenuKeys); - window.removeEventListener('click', handleClickOutside); - }; - }, [isOpen, menuRef]); - const onSelect = (event: React.MouseEvent | undefined, itemId: string | number | undefined) => { if (typeof itemId === 'string' || typeof itemId === 'undefined') { return; @@ -47,32 +22,27 @@ export const ComposableActionsMenu: React.FunctionComponent = () => { } }; - const onToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); // Stop handleClickOutside from handling - setTimeout(() => { - if (menuRef.current) { - const firstElement = menuRef.current.querySelector('li > button:not(:disabled), li > a:not(:disabled)'); - firstElement && (firstElement as HTMLElement).focus(); - } - }, 0); + const onToggleClick = () => { setIsOpen(!isOpen); }; - const toggle = ( - - {isOpen ? 'Expanded' : 'Collapsed'} - - ); - const menu = ( - ( + + {isOpen ? 'Expanded' : 'Collapsed'} + + )} // eslint-disable-next-line no-console onActionClick={(event, itemId, actionId) => console.log(`clicked on ${itemId} - ${actionId}`)} onSelect={onSelect} + onOpenChange={(isOpen) => setIsOpen(isOpen)} > - - - + + { itemId={0} > Item 1 - - + } actionId="alert" aria-label="Alert" />} @@ -96,26 +66,24 @@ export const ComposableActionsMenu: React.FunctionComponent = () => { itemId={1} > Item 2 - - + } actionId="copy" aria-label="Copy" />} itemId={2} > Item 3 - - + } actionId="expand" aria-label="Expand" />} description="This is a description" itemId={3} > Item 4 - - - - + + + + ); - - return ; }; diff --git a/packages/react-core/src/demos/ComposableMenu/examples/ComposableApplicationLauncher.tsx b/packages/react-core/src/demos/CustomMenus/examples/ApplicationLauncherDemo.tsx similarity index 62% rename from packages/react-core/src/demos/ComposableMenu/examples/ComposableApplicationLauncher.tsx rename to packages/react-core/src/demos/CustomMenus/examples/ApplicationLauncherDemo.tsx index cf43bb18172..35603c3ca1f 100644 --- a/packages/react-core/src/demos/ComposableMenu/examples/ComposableApplicationLauncher.tsx +++ b/packages/react-core/src/demos/CustomMenus/examples/ApplicationLauncherDemo.tsx @@ -1,50 +1,29 @@ import React from 'react'; import { MenuToggle, - Menu, - MenuContent, - MenuList, - MenuItem, - MenuGroup, MenuSearch, MenuSearchInput, - Popper, Tooltip, Divider, - SearchInput + SearchInput, + Dropdown, + DropdownGroup, + DropdownList, + DropdownItem } from '@patternfly/react-core'; -import { Link } from '@reach/router'; import ThIcon from '@patternfly/react-icons/dist/js/icons/th-icon'; import pfIcon from 'pf-logo-small.svg'; -export const ComposableApplicationLauncher: React.FunctionComponent = () => { +const MockLink: React.FunctionComponent = ({ to, ...props }: any) => ; + +export const ApplicationLauncherDemo: React.FunctionComponent = () => { const [isOpen, setIsOpen] = React.useState(false); const [refFullOptions, setRefFullOptions] = React.useState(); const [favorites, setFavorites] = React.useState([]); const [filteredIds, setFilteredIds] = React.useState(['*']); const menuRef = React.useRef(null); - const toggleRef = React.useRef(null); - const handleMenuKeys = (event: KeyboardEvent) => { - if (!isOpen) { - return; - } - if (menuRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)) { - if (event.key === 'Escape') { - setIsOpen(!isOpen); - toggleRef.current?.focus(); - } - } - }; - - const handleClickOutside = (event: MouseEvent) => { - if (isOpen && !menuRef.current?.contains(event.target as Node)) { - setIsOpen(false); - } - }; - - const onToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); // Stop handleClickOutside from handling + const onToggleClick = () => { setTimeout(() => { if (menuRef.current) { const firstElement = menuRef.current.querySelector( @@ -57,36 +36,13 @@ export const ComposableApplicationLauncher: React.FunctionComponent = () => { setIsOpen(!isOpen); }; - React.useEffect(() => { - window.addEventListener('keydown', handleMenuKeys); - window.addEventListener('click', handleClickOutside); - - return () => { - window.removeEventListener('keydown', handleMenuKeys); - window.removeEventListener('click', handleClickOutside); - }; - }, [isOpen, menuRef]); - - const toggle = ( - - - - ); - const menuItems = [ - - - + + + Application 1 - - + { onClick={(ev) => ev.preventDefault()} > Application 2 - - - , + + + , , - - - + + } + component={(props) => } > - @reach/router Link - - + } - component={(props) => } + component={(props) => } > - @reach/router Link with icon - - - , + Custom component with icon + + + , , - - + + Launch Application 3} position="right"> Application 3 with tooltip - - + + Unavailable Application - - + + ]; const createFavorites = (favIds: string[]) => { const favorites: unknown[] = []; menuItems.forEach((item) => { - if (item.type === MenuList) { + if (item.type === DropdownList) { item.props.children.filter((child) => { if (favIds.includes(child.props.itemId)) { favorites.push(child); } }); - } else if (item.type === MenuGroup) { + } else if (item.type === DropdownGroup) { item.props.children.props.children.filter((child) => { if (favIds.includes(child.props.itemId)) { favorites.push(child); @@ -166,7 +122,7 @@ export const ComposableApplicationLauncher: React.FunctionComponent = () => { let keepDivider = false; const filteredCopy = items .map((group) => { - if (group.type === MenuGroup) { + if (group.type === DropdownGroup) { const filteredGroup = React.cloneElement(group, { children: React.cloneElement(group.props.children, { children: group.props.children.props.children.filter((child) => { @@ -183,7 +139,7 @@ export const ComposableApplicationLauncher: React.FunctionComponent = () => { } else { keepDivider = false; } - } else if (group.type === MenuList) { + } else if (group.type === DropdownList) { const filteredGroup = React.cloneElement(group, { children: group.props.children.filter((child) => { if (filteredIds.includes(child.props.itemId)) { @@ -243,30 +199,47 @@ export const ComposableApplicationLauncher: React.FunctionComponent = () => { const filteredFavorites = filterItems(createFavorites(favorites), filteredIds); const filteredItems = filterItems(menuItems, filteredIds); if (filteredItems.length === 0) { - filteredItems.push(No results found); + filteredItems.push(No results found); } - const menu = ( + return ( // eslint-disable-next-line no-console - console.log('selected', itemId)}> + setIsOpen(isOpen)} + onOpenChangeKeys={['Escape']} + toggle={(toggleRef) => ( + + + + )} + ref={menuRef} + onActionClick={onFavorite} + // eslint-disable-next-line no-console + onSelect={(_ev, itemId) => console.log('selected', itemId)} + > onTextChange(value)} /> - - {filteredFavorites.length > 0 && ( - - - {filteredFavorites} - - - - )} - {filteredItems} - - + {filteredFavorites.length > 0 && ( + + + {filteredFavorites} + + + + )} + {filteredItems} + ); - return ; }; diff --git a/packages/react-core/src/demos/ComposableMenu/examples/ComposableDateSelect.tsx b/packages/react-core/src/demos/CustomMenus/examples/ComposableDateSelect.tsx similarity index 100% rename from packages/react-core/src/demos/ComposableMenu/examples/ComposableDateSelect.tsx rename to packages/react-core/src/demos/CustomMenus/examples/ComposableDateSelect.tsx diff --git a/packages/react-core/src/demos/ComposableMenu/examples/ComposableDropdwnVariants.tsx b/packages/react-core/src/demos/CustomMenus/examples/ComposableDropdwnVariants.tsx similarity index 100% rename from packages/react-core/src/demos/ComposableMenu/examples/ComposableDropdwnVariants.tsx rename to packages/react-core/src/demos/CustomMenus/examples/ComposableDropdwnVariants.tsx diff --git a/packages/react-core/src/demos/ComposableMenu/examples/ComposableContextSelector.tsx b/packages/react-core/src/demos/CustomMenus/examples/ContextSelectorDemo.tsx similarity index 54% rename from packages/react-core/src/demos/ComposableMenu/examples/ComposableContextSelector.tsx rename to packages/react-core/src/demos/CustomMenus/examples/ContextSelectorDemo.tsx index 9a0e6a8151e..e7ceaaaeaf1 100644 --- a/packages/react-core/src/demos/ComposableMenu/examples/ComposableContextSelector.tsx +++ b/packages/react-core/src/demos/CustomMenus/examples/ContextSelectorDemo.tsx @@ -1,32 +1,30 @@ import React from 'react'; import { MenuToggle, - Menu, - MenuContent, MenuFooter, - MenuList, - MenuItem, MenuSearch, MenuSearchInput, - Popper, Divider, InputGroup, InputGroupItem, Button, ButtonVariant, - SearchInput + SearchInput, + Dropdown, + DropdownList, + DropdownItem } from '@patternfly/react-core'; import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon'; interface ItemData { text: string; href?: string; - isDisabled?: boolean; + isDisabled?: boolean | undefined; } type ItemArrayType = (ItemData | string)[]; -export const ComposableContextSelector: React.FunctionComponent = () => { +export const ContextSelectorDemo: React.FunctionComponent = () => { const items: ItemArrayType = [ { text: 'Action' @@ -60,66 +58,16 @@ export const ComposableContextSelector: React.FunctionComponent = () => { const [filteredItems, setFilteredItems] = React.useState(items); const [searchInputValue, setSearchInputValue] = React.useState(''); const menuRef = React.useRef(null); - const toggleRef = React.useRef(null); const menuFooterBtnRef = React.useRef(null); - const handleMenuKeys = (event: KeyboardEvent) => { - if (!isOpen) { - return; - } - if (menuFooterBtnRef.current?.contains(event.target as Node)) { - if (event.key === 'Tab') { - if (event.shiftKey) { - return; - } - setIsOpen(!isOpen); - toggleRef.current?.focus(); - } - } - if (menuRef.current?.contains(event.target as Node)) { - if (event.key === 'Escape') { - setIsOpen(!isOpen); - toggleRef.current?.focus(); - } - } - }; - - const handleClickOutside = (event: MouseEvent) => { - if (isOpen && !menuRef.current?.contains(event.target as Node)) { - setIsOpen(false); - } - }; - - React.useEffect(() => { - window.addEventListener('keydown', handleMenuKeys); - window.addEventListener('click', handleClickOutside); - - return () => { - window.removeEventListener('keydown', handleMenuKeys); - window.removeEventListener('click', handleClickOutside); - }; - }, [isOpen, menuRef]); - - const onToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); // Stop handleClickOutside from handling - setTimeout(() => { - if (menuRef.current) { - const firstElement = menuRef.current.querySelector( - 'li > button:not(:disabled), li > a:not(:disabled), input:not(:disabled)' - ); - firstElement && (firstElement as HTMLElement).focus(); - } - }, 0); + const onToggleClick = () => { setIsOpen(!isOpen); }; - const toggle = ( - - {selected} - - ); - - const onSelect = (ev: React.MouseEvent, itemId: string | number) => { + const onSelect = (ev: React.MouseEvent | undefined, itemId: string | number | undefined) => { + if (typeof itemId === 'number' || typeof itemId === 'undefined') { + return; + } setSelected(itemId.toString()); setIsOpen(!isOpen); }; @@ -147,8 +95,21 @@ export const ComposableContextSelector: React.FunctionComponent = () => { } }; - const menu = ( - + return ( + setIsOpen(isOpen)} + onOpenChangeKeys={['Escape']} + toggle={(toggleRef) => ( + + {selected} + + )} + ref={menuRef} + id="context-selector" + onSelect={onSelect} + isScrollable + > @@ -175,25 +136,27 @@ export const ComposableContextSelector: React.FunctionComponent = () => { - - - {filteredItems.map((item, index) => { - const [itemText, isDisabled, href] = - typeof item === 'string' ? [item, null, null] : [item.text, item.isDisabled || null, item.href || null]; - return ( - - {itemText} - - ); - })} - - + + {filteredItems.map((item, index) => { + const [itemText, isDisabled, href] = + typeof item === 'string' ? [item, null, null] : [item.text, item.isDisabled || null, item.href || null]; + return ( + + {itemText} + + ); + })} + - + ); - return ; }; diff --git a/packages/react-core/src/demos/CustomMenus/examples/DateSelectDemo.tsx b/packages/react-core/src/demos/CustomMenus/examples/DateSelectDemo.tsx new file mode 100644 index 00000000000..63cb9077eee --- /dev/null +++ b/packages/react-core/src/demos/CustomMenus/examples/DateSelectDemo.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { MenuToggle, Select, SelectList, SelectOption } from '@patternfly/react-core'; + +export const DateSelectDemo: React.FunctionComponent = () => { + const [isOpen, setIsOpen] = React.useState(false); + const [selected, setSelected] = React.useState(0); + const menuRef = React.useRef(); + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const monthStrings = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December' + ]; + + const dateString = (date: Date) => `${monthStrings[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`; + + const date = new Date(); + + const toggleText = { + 0: 'Today ', + 1: 'Yesterday ', + 2: 'Last 7 days ', + 3: 'Last 14 days ' + }; + + const dateText = { + 0: ({dateString(date)}), + 1: ( + + ({dateString(new Date(new Date().setDate(date.getDate() - 1)))} - {dateString(date)}) + + ), + 2: ( + + ({dateString(new Date(new Date().setDate(date.getDate() - 7)))} - {dateString(date)}) + + ), + 3: ( + + ({dateString(new Date(new Date().setDate(date.getDate() - 14)))} - {dateString(date)}) + + ) + }; + + return ( + // eslint-disable-next-line no-console + + ); +}; diff --git a/packages/react-core/src/demos/ComposableMenu/examples/ComposableDrilldownMenu.tsx b/packages/react-core/src/demos/CustomMenus/examples/DrilldownMenuDemo.tsx similarity index 85% rename from packages/react-core/src/demos/ComposableMenu/examples/ComposableDrilldownMenu.tsx rename to packages/react-core/src/demos/CustomMenus/examples/DrilldownMenuDemo.tsx index f25da999673..7347dc24dbd 100644 --- a/packages/react-core/src/demos/ComposableMenu/examples/ComposableDrilldownMenu.tsx +++ b/packages/react-core/src/demos/CustomMenus/examples/DrilldownMenuDemo.tsx @@ -7,7 +7,7 @@ import { MenuItem, DrilldownMenu, Divider, - Popper + MenuContainer } from '@patternfly/react-core'; import StorageDomainIcon from '@patternfly/react-icons/dist/esm/icons/storage-domain-icon'; import CodeBranchIcon from '@patternfly/react-icons/dist/esm/icons/code-branch-icon'; @@ -18,7 +18,7 @@ interface MenuHeightsType { [id: string]: number; } -export const ComposableDrilldownMenu: React.FunctionComponent = () => { +export const DrilldownMenuDemo: React.FunctionComponent = () => { const [isOpen, setIsOpen] = React.useState(false); const [activeMenu, setActiveMenu] = React.useState('rootMenu'); const [menuDrilledIn, setMenuDrilledIn] = React.useState([]); @@ -27,38 +27,7 @@ export const ComposableDrilldownMenu: React.FunctionComponent = () => { const toggleRef = React.useRef(null); const menuRef = React.useRef(null); - const handleMenuKeys = (event: KeyboardEvent) => { - if (isOpen && menuRef.current?.contains(event.target as Node)) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsOpen(!isOpen); - toggleRef.current?.focus(); - } - } - }; - - const handleClickOutside = (event: MouseEvent) => { - if (isOpen && !menuRef.current?.contains(event.target as Node)) { - setIsOpen(false); - } - }; - - React.useEffect(() => { - window.addEventListener('keydown', handleMenuKeys); - window.addEventListener('click', handleClickOutside); - return () => { - window.removeEventListener('keydown', handleMenuKeys); - window.removeEventListener('click', handleClickOutside); - }; - }, [isOpen, menuRef]); - - const onToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); // Stop handleClickOutside from handling - setTimeout(() => { - if (menuRef.current) { - const firstElement = menuRef.current.querySelector('li > button:not(:disabled), li > a:not(:disabled)'); - firstElement && (firstElement as HTMLElement).focus(); - } - }, 0); + const onToggleClick = () => { setIsOpen(!isOpen); setMenuDrilledIn([]); setDrilldownPath([]); @@ -83,7 +52,7 @@ export const ComposableDrilldownMenu: React.FunctionComponent = () => { }; const setHeight = (menuId: string, height: number) => { - if (!menuHeights[menuId]) { + if (!menuHeights[menuId] || (menuId !== 'rootMenu' && menuHeights[menuId] !== height)) { setMenuHeights({ ...menuHeights, [menuId]: height @@ -242,5 +211,14 @@ export const ComposableDrilldownMenu: React.FunctionComponent = () => { ); - return ; + return ( + setIsOpen(isOpen)} + menu={menu} + menuRef={menuRef} + toggle={toggle} + toggleRef={toggleRef} + /> + ); }; diff --git a/packages/react-core/src/demos/CustomMenus/examples/FavoritesDemo.tsx b/packages/react-core/src/demos/CustomMenus/examples/FavoritesDemo.tsx new file mode 100644 index 00000000000..1604a1f8e49 --- /dev/null +++ b/packages/react-core/src/demos/CustomMenus/examples/FavoritesDemo.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { MenuToggle, Divider, Dropdown, DropdownGroup, DropdownList, DropdownItem } from '@patternfly/react-core'; + +export const FavoritesDemo: React.FunctionComponent = () => { + const [isOpen, setIsOpen] = React.useState(false); + const [favorites, setFavorites] = React.useState([]); + const menuRef = React.useRef(null); + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const menuItems = [ + + + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + , + , + + + + Item 5 + + + Item 6 + + + Item 7 + + + Item 8 + + + + ]; + + const createFavorites = (favIds: string[]) => { + const favorites: JSX.Element[] = []; + + menuItems.forEach((item) => { + if (item.type === DropdownList) { + item.props.children.filter((child) => { + if (favIds.includes(child.props.itemId)) { + favorites.push(child); + } + }); + } else if (item.type === DropdownGroup) { + item.props.children.props.children.filter((child) => { + if (favIds.includes(child.props.itemId)) { + favorites.push(child); + } + }); + } else { + if (favIds.includes(item.props.itemId)) { + favorites.push(item); + } + } + }); + + return favorites; + }; + + React.useEffect(() => { + if (favorites.length === 0) { + const firstElement = menuRef?.current?.querySelector('li > button:not(:disabled)'); + firstElement && (firstElement as HTMLElement).focus(); + } + }, [favorites]); + + const onFavorite = (event: any, itemId: string, actionId: string) => { + event.stopPropagation(); + if (actionId === 'fav') { + const isFavorite = favorites.includes(itemId); + if (isFavorite) { + setFavorites(favorites.filter((fav) => fav !== itemId)); + } else { + setFavorites([...favorites, itemId]); + } + } + }; + + return ( + // eslint-disable-next-line no-console + setIsOpen(isOpen)} + toggle={(toggleRef) => ( + + {isOpen ? 'Expanded' : 'Collapsed'} + + )} + ref={menuRef} + onActionClick={onFavorite} + // eslint-disable-next-line no-console + onSelect={(_ev, itemId) => console.log('selected', itemId)} + > + {favorites.length > 0 && ( + + + {createFavorites(favorites)} + + + + )} + {menuItems} + + ); +}; diff --git a/packages/react-core/src/demos/ComposableMenu/examples/ComposableFlyout.tsx b/packages/react-core/src/demos/CustomMenus/examples/FlyoutDemo.tsx similarity index 56% rename from packages/react-core/src/demos/ComposableMenu/examples/ComposableFlyout.tsx rename to packages/react-core/src/demos/CustomMenus/examples/FlyoutDemo.tsx index 0043decf203..a4bec252174 100644 --- a/packages/react-core/src/demos/ComposableMenu/examples/ComposableFlyout.tsx +++ b/packages/react-core/src/demos/CustomMenus/examples/FlyoutDemo.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { MenuToggle, Menu, MenuContent, MenuList, MenuItem, Popper } from '@patternfly/react-core'; +import { MenuToggle, Menu, MenuContent, MenuList, MenuItem, MenuContainer } from '@patternfly/react-core'; /* eslint-disable no-console */ const onSelect = (event: React.MouseEvent | undefined, itemId: string | number | undefined) => @@ -30,56 +30,22 @@ const FlyoutMenu: React.FunctionComponent = ({ depth, children ); -export const ComposableFlyout: React.FunctionComponent = () => { +export const FlyoutDemo: React.FunctionComponent = () => { const [isOpen, setIsOpen] = React.useState(false); const menuRef = React.useRef(null); const toggleRef = React.useRef(null); - const handleMenuKeys = (event: KeyboardEvent) => { - if (!isOpen) { - return; - } - if (menuRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)) { - if (event.key === 'Escape' || event.key === 'Tab') { - setIsOpen(!isOpen); - toggleRef.current?.focus(); - } - } - }; - - const handleClickOutside = (event: MouseEvent) => { - if (isOpen && !menuRef.current?.contains(event.target as Node)) { - setIsOpen(false); - } - }; - - React.useEffect(() => { - window.addEventListener('keydown', handleMenuKeys); - window.addEventListener('click', handleClickOutside); - return () => { - window.removeEventListener('keydown', handleMenuKeys); - window.removeEventListener('click', handleClickOutside); - }; - }, [isOpen, menuRef]); - let curFlyout = ; for (let i = 2; i < 14; i++) { curFlyout = {curFlyout}; } - const onToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); // Stop handleClickOutside from handling - setTimeout(() => { - if (menuRef.current) { - const firstElement = menuRef.current.querySelector('li > button:not(:disabled), li > a:not(:disabled)'); - firstElement && (firstElement as HTMLElement).focus(); - } - }, 0); + const onToggleClick = () => { setIsOpen(!isOpen); }; const toggle = ( - + {isOpen ? 'Expanded' : 'Collapsed'} ); @@ -100,5 +66,14 @@ export const ComposableFlyout: React.FunctionComponent = () => { ); - return ; + return ( + setIsOpen(isOpen)} + menu={menu} + menuRef={menuRef} + toggle={toggle} + toggleRef={toggleRef} + /> + ); }; diff --git a/packages/react-core/src/demos/CustomMenus/examples/OptionsMenuDemo.tsx b/packages/react-core/src/demos/CustomMenus/examples/OptionsMenuDemo.tsx new file mode 100644 index 00000000000..a0cc9fa4372 --- /dev/null +++ b/packages/react-core/src/demos/CustomMenus/examples/OptionsMenuDemo.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { MenuToggle, Divider, Select, SelectList, SelectOption, SelectGroup } from '@patternfly/react-core'; + +export const OptionsMenuDemo: React.FunctionComponent = () => { + const [isOpen, setIsOpen] = React.useState(false); + const [selected, setSelected] = React.useState(''); + const menuRef = React.useRef(); + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + return ( + + ); +}; diff --git a/packages/react-core/src/demos/ComposableMenu/examples/ComposableTreeViewMenu.tsx b/packages/react-core/src/demos/CustomMenus/examples/TreeViewMenuDemo.tsx similarity index 73% rename from packages/react-core/src/demos/ComposableMenu/examples/ComposableTreeViewMenu.tsx rename to packages/react-core/src/demos/CustomMenus/examples/TreeViewMenuDemo.tsx index b3380281eab..e46c9dc3837 100644 --- a/packages/react-core/src/demos/ComposableMenu/examples/ComposableTreeViewMenu.tsx +++ b/packages/react-core/src/demos/CustomMenus/examples/TreeViewMenuDemo.tsx @@ -5,12 +5,12 @@ import { PanelMain, PanelMainBody, Title, - Popper, + MenuContainer, TreeView, TreeViewDataItem } from '@patternfly/react-core'; -export const ComposableTreeViewMenu: React.FunctionComponent = () => { +export const TreeViewMenuDemo: React.FunctionComponent = () => { const [isOpen, setIsOpen] = React.useState(false); const [checkedItems, setCheckedItems] = React.useState([]); const toggleRef = React.useRef(null); @@ -166,55 +166,7 @@ export const ComposableTreeViewMenu: React.FunctionComponent = () => { ); }; - // Controls keys that should open/close the menu - const handleMenuKeys = (event: KeyboardEvent) => { - if (!isOpen) { - return; - } - if (menuRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)) { - // The escape key when pressed while inside the menu should close the menu and refocus the toggle - if (event.key === 'Escape') { - setIsOpen(!isOpen); - toggleRef.current?.focus(); - } - - // The tab key when pressed while inside the menu and on the contained last tree view should close the menu and refocus the toggle - // Shift tab should keep the default behavior to return to a previous tree view - if (event.key === 'Tab' && !event.shiftKey) { - const treeList = menuRef.current?.querySelectorAll('.pf-c-tree-view') || []; - if (treeList[treeList.length - 1].contains(event.target as Node)) { - event.preventDefault(); - setIsOpen(!isOpen); - toggleRef.current?.focus(); - } - } - } - }; - - // Controls that a click outside the menu while the menu is open should close the menu - const handleClickOutside = (event: MouseEvent) => { - if (isOpen && !menuRef.current?.contains(event.target as Node)) { - setIsOpen(false); - } - }; - - React.useEffect(() => { - window.addEventListener('keydown', handleMenuKeys); - window.addEventListener('click', handleClickOutside); - return () => { - window.removeEventListener('keydown', handleMenuKeys); - window.removeEventListener('click', handleClickOutside); - }; - }, [isOpen, menuRef]); - - const onToggleClick = (ev: React.MouseEvent) => { - ev.stopPropagation(); // Stop handleClickOutside from handling - setTimeout(() => { - if (menuRef.current) { - const firstElement = menuRef.current.querySelector('li > button:not(:disabled), li > a:not(:disabled)'); - firstElement && (firstElement as HTMLElement).focus(); - } - }, 0); + const onToggleClick = () => { setIsOpen(!isOpen); }; @@ -267,5 +219,16 @@ export const ComposableTreeViewMenu: React.FunctionComponent = () => { ); - return ; + + return ( + setIsOpen(isOpen)} + onOpenChangeKeys={['Escape']} + menu={menu} + menuRef={menuRef} + toggle={toggle} + toggleRef={toggleRef} + /> + ); }; diff --git a/packages/react-core/src/demos/ComposableMenu/examples/avatarImg.svg b/packages/react-core/src/demos/CustomMenus/examples/avatarImg.svg similarity index 100% rename from packages/react-core/src/demos/ComposableMenu/examples/avatarImg.svg rename to packages/react-core/src/demos/CustomMenus/examples/avatarImg.svg diff --git a/packages/react-core/src/demos/ComposableMenu/examples/pf-logo-small.svg b/packages/react-core/src/demos/CustomMenus/examples/pf-logo-small.svg similarity index 100% rename from packages/react-core/src/demos/ComposableMenu/examples/pf-logo-small.svg rename to packages/react-core/src/demos/CustomMenus/examples/pf-logo-small.svg