From db3f6563bda37e5f273493ea853e88a4be926681 Mon Sep 17 00:00:00 2001 From: Kevin Cormier <kcormier@redhat.com> Date: Fri, 24 Mar 2023 17:13:34 -0400 Subject: [PATCH] WIP: Convert AcmDropdown to use Menu Signed-off-by: Kevin Cormier <kcormier@redhat.com> --- frontend/src/components/Rbac.tsx | 42 ++- frontend/src/routes/Applications/Overview.tsx | 6 +- .../components/PolicyActionDropdown.tsx | 302 +++++++++--------- .../components/ClusterActionDropdown.tsx | 3 +- .../DownloadConfigurationDropdown.tsx | 2 + .../AcmDropdown/AcmDropdown.stories.tsx | 2 +- .../ui-components/AcmDropdown/AcmDropdown.tsx | 269 ++++++++++------ 7 files changed, 364 insertions(+), 262 deletions(-) diff --git a/frontend/src/components/Rbac.tsx b/frontend/src/components/Rbac.tsx index 798aa3e79be..357908d0403 100644 --- a/frontend/src/components/Rbac.tsx +++ b/frontend/src/components/Rbac.tsx @@ -2,9 +2,11 @@ import { makeStyles } from '@mui/styles' import { createSubjectAccessReview, ResourceAttributes } from '../resources' -import { AcmButton, AcmDropdown } from '../ui-components' -import { useEffect, useState } from 'react' +import { AcmButton, AcmDropdown, AcmDropdownItems } from '../ui-components' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from '../lib/acm-i18next' +import { Actions } from '../routes/Infrastructure/Clusters/ManagedClusters/CreateCluster/components/assisted-installer/hypershift/common/common' +import { DropdownPosition } from '@patternfly/react-core' type RbacDropdownProps<T = unknown> = { actions: Actions<T>[] @@ -14,17 +16,23 @@ type RbacDropdownProps<T = unknown> = { id: string isDisabled?: boolean tooltip?: string + dropdownPosition?: DropdownPosition } -type Actions<T = unknown> = { - id: string - text: React.ReactNode - isAriaDisabled?: boolean - tooltip?: string - click: (item: T) => void +type Actions<T = unknown> = AcmDropdownItems & { + flyoutMenu?: Actions<T>[] + click?: (item: T) => void rbac?: ResourceAttributes[] | Promise<ResourceAttributes>[] } +function flattenAction<T>(action: Actions<T>): Actions<T> | Actions<T>[] { + return action.flyoutMenu ? flattenActions(action.flyoutMenu) : action +} + +function flattenActions<T>(actions: Actions<T>[]): Actions<T>[] { + return actions.flatMap(flattenAction) +} + export function RbacDropdown<T = unknown>(props: RbacDropdownProps<T>) { const { t } = useTranslation() const [actions, setActions] = useState<Actions<T>[]>([]) @@ -36,10 +44,19 @@ export function RbacDropdown<T = unknown>(props: RbacDropdownProps<T>) { } }, [actions, props.actions]) - const onSelect = (id: string) => { - const action = props.actions.find((a) => a.id === id) - return action?.click(props.item) - } + const actionsWithFlyoutActions = useMemo(() => { + return flattenActions<T>(actions) + }, [actions]) + + const onSelect = useCallback( + (id: string) => { + const action = actionsWithFlyoutActions.find((a) => a.id === id) + if (action?.click) { + action.click(props.item) + } + }, + [actionsWithFlyoutActions, props.item] + ) const onToggle = async (isOpen?: boolean) => { if (isOpen) { @@ -75,6 +92,7 @@ export function RbacDropdown<T = unknown>(props: RbacDropdownProps<T>) { id={props.id} onSelect={onSelect} dropdownItems={actions} + dropdownPosition={props.dropdownPosition} isKebab={props.isKebab} isPlain={true} text={props.text} diff --git a/frontend/src/routes/Applications/Overview.tsx b/frontend/src/routes/Applications/Overview.tsx index bbe36b4bde2..c7a9e85ab52 100644 --- a/frontend/src/routes/Applications/Overview.tsx +++ b/frontend/src/routes/Applications/Overview.tsx @@ -1,6 +1,5 @@ /* Copyright Contributors to the Open Cluster Management project */ - -import { PageSection, Text, TextContent, TextVariants, Tooltip } from '@patternfly/react-core' +import { DropdownPosition, PageSection, Text, TextContent, TextVariants, Tooltip } from '@patternfly/react-core' import { ExternalLinkAltIcon, OutlinedQuestionCircleIcon } from '@patternfly/react-icons' import { cellWidth } from '@patternfly/react-table' import { @@ -1023,8 +1022,7 @@ export default function ApplicationsOverview() { isKebab={false} isPlain={false} isPrimary={true} - // tooltipPosition={tableDropdown.tooltipPosition} - // dropdownPosition={DropdownPosition.left} + dropdownPosition={DropdownPosition.left} /> ), [canCreateApplication, history, t] diff --git a/frontend/src/routes/Governance/components/PolicyActionDropdown.tsx b/frontend/src/routes/Governance/components/PolicyActionDropdown.tsx index e8aa2db58b5..4164ee66eab 100644 --- a/frontend/src/routes/Governance/components/PolicyActionDropdown.tsx +++ b/frontend/src/routes/Governance/components/PolicyActionDropdown.tsx @@ -74,159 +74,175 @@ export function PolicyActionDropdown(props: { rbac: [rbacPatch(PolicyDefinition, item.policy.metadata.namespace)], }, { - id: 'enable-policy', - text: t('Enable'), - tooltip: item.policy.spec.disabled ? t('Enable policy') : t('Policy is already enabled'), - isAriaDisabled: item.policy.spec.disabled === false, - click: (item: PolicyTableItem) => { - setModalProps({ - open: true, - title: t('policy.modal.title.enable'), - action: t('policy.table.actions.enable'), - processing: t('policy.table.actions.enabling'), - items: [item], - emptyState: undefined, // there is always 1 item supplied - description: t('policy.modal.message.enable'), - columns: bulkModalStatusColumns, - keyFn: (item: PolicyTableItem) => item.policy.metadata.uid as string, - actionFn: (item: PolicyTableItem) => { - return patchResource( - { - apiVersion: PolicyApiVersion, - kind: PolicyKind, - metadata: { - name: item.policy.metadata.name, - namespace: item.policy.metadata.namespace, + id: 'status-policy', + text: t('Status'), + rbac: [rbacPatch(PolicyDefinition, item.policy.metadata.namespace)], + flyoutMenu: [ + { + id: 'enable-policy', + text: t('Enable'), + tooltip: item.policy.spec.disabled ? t('Enable policy') : t('Policy is already enabled'), + isSelected: !item.policy.spec.disabled, + click: (item: PolicyTableItem) => { + if (item.policy.spec.disabled) { + setModalProps({ + open: true, + title: t('policy.modal.title.enable'), + action: t('policy.table.actions.enable'), + processing: t('policy.table.actions.enabling'), + items: [item], + emptyState: undefined, // there is always 1 item supplied + description: t('policy.modal.message.enable'), + columns: bulkModalStatusColumns, + keyFn: (item: PolicyTableItem) => item.policy.metadata.uid as string, + actionFn: (item: PolicyTableItem) => { + return patchResource( + { + apiVersion: PolicyApiVersion, + kind: PolicyKind, + metadata: { + name: item.policy.metadata.name, + namespace: item.policy.metadata.namespace, + }, + } as Policy, + [{ op: 'replace', path: '/spec/disabled', value: false }] + ) }, - } as Policy, - [{ op: 'replace', path: '/spec/disabled', value: false }] - ) - }, - close: () => { - setModalProps({ open: false }) - }, - hasExternalResources: item.source !== 'Local', - }) - }, - rbac: item.policy.spec.disabled ? [rbacPatch(PolicyDefinition, item.policy.metadata.namespace)] : undefined, - }, - { - id: 'disable-policy', - text: t('policy.table.actions.disable'), - tooltip: item.policy.spec.disabled ? t('Policy is already disabled') : t('Disable policy'), - isAriaDisabled: item.policy.spec.disabled === true, - click: (item: PolicyTableItem) => { - setModalProps({ - open: true, - title: t('policy.modal.title.disable'), - action: t('policy.table.actions.disable'), - processing: t('policy.table.actions.disabling'), - items: [item], - emptyState: undefined, // there is always 1 item supplied - description: t('policy.modal.message.disable'), - columns: bulkModalStatusColumns, - keyFn: (item: PolicyTableItem) => item.policy.metadata.uid as string, - actionFn: (item) => { - return patchResource( - { - apiVersion: PolicyApiVersion, - kind: PolicyKind, - metadata: { - name: item.policy.metadata.name, - namespace: item.policy.metadata.namespace, + close: () => { + setModalProps({ open: false }) }, - } as Policy, - [{ op: 'replace', path: '/spec/disabled', value: true }] - ) - }, - close: () => { - setModalProps({ open: false }) + hasExternalResources: item.source !== 'Local', + }) + } }, - hasExternalResources: item.source !== 'Local', - }) - }, - rbac: item.policy.spec.disabled ? undefined : [rbacPatch(PolicyDefinition, item.policy.metadata.namespace)], - }, - { - id: 'inform-policy', - text: t('policy.table.actions.inform'), - tooltip: policyRemediationAction === 'inform' ? t('Already informing') : t('Inform policy'), - addSeparator: true, - isAriaDisabled: policyRemediationAction === 'inform', - click: (item: PolicyTableItem) => { - setModalProps({ - open: true, - title: t('policy.modal.title.inform'), - action: t('policy.table.actions.inform'), - processing: t('policy.table.actions.informing'), - items: [item], - emptyState: undefined, // there is always 1 item supplied - description: t('policy.modal.message.inform'), - columns: bulkModalRemediationColumns, - keyFn: (item: PolicyTableItem) => item.policy.metadata.uid as string, - actionFn: (item) => { - return patchResource( - { - apiVersion: PolicyApiVersion, - kind: PolicyKind, - metadata: { - name: item.policy.metadata.name, - namespace: item.policy.metadata.namespace, + }, + { + id: 'disable-policy', + text: t('policy.table.actions.disable'), + tooltip: item.policy.spec.disabled ? t('Policy is already disabled') : t('Disable policy'), + isSelected: item.policy.spec.disabled, + click: (item: PolicyTableItem) => { + if (!item.policy.spec.disabled) { + setModalProps({ + open: true, + title: t('policy.modal.title.disable'), + action: t('policy.table.actions.disable'), + processing: t('policy.table.actions.disabling'), + items: [item], + emptyState: undefined, // there is always 1 item supplied + description: t('policy.modal.message.disable'), + columns: bulkModalStatusColumns, + keyFn: (item: PolicyTableItem) => item.policy.metadata.uid as string, + actionFn: (item) => { + return patchResource( + { + apiVersion: PolicyApiVersion, + kind: PolicyKind, + metadata: { + name: item.policy.metadata.name, + namespace: item.policy.metadata.namespace, + }, + } as Policy, + [{ op: 'replace', path: '/spec/disabled', value: true }] + ) }, - } as Policy, - [{ op: 'replace', path: '/spec/remediationAction', value: 'inform' }] - ) - }, - close: () => { - setModalProps({ open: false }) + close: () => { + setModalProps({ open: false }) + }, + hasExternalResources: item.source !== 'Local', + }) + } }, - hasExternalResources: item.source !== 'Local', - }) - }, - rbac: - policyRemediationAction === 'inform' - ? undefined - : [rbacPatch(PolicyDefinition, item.policy.metadata.namespace)], + }, + ], }, { - id: 'enforce-policy', - text: t('policy.table.actions.enforce'), - tooltip: policyRemediationAction === 'enforce' ? t('Already enforcing') : t('Enforce policy'), - isAriaDisabled: policyRemediationAction === 'enforce', - click: (item: PolicyTableItem) => { - setModalProps({ - open: true, - title: t('policy.modal.title.enforce'), - action: t('policy.table.actions.enforce'), - processing: t('policy.table.actions.enforcing'), - items: [item], - emptyState: undefined, // there is always 1 item supplied - description: t('policy.modal.message.enforce'), - columns: bulkModalRemediationColumns, - keyFn: (item: PolicyTableItem) => item.policy.metadata.uid as string, - actionFn: (item) => { - return patchResource( - { - apiVersion: PolicyApiVersion, - kind: PolicyKind, - metadata: { - name: item.policy.metadata.name, - namespace: item.policy.metadata.namespace, + id: 'remediation-policy', + text: t('Remediation'), + rbac: [rbacPatch(PolicyDefinition, item.policy.metadata.namespace)], + flyoutMenu: [ + { + id: 'inform-policy', + text: t('policy.table.actions.inform'), + tooltip: policyRemediationAction === 'inform' ? t('Already informing') : t('Inform policy'), + addSeparator: true, + isSelected: policyRemediationAction === 'inform', + click: (item: PolicyTableItem) => { + if (policyRemediationAction !== 'inform') { + setModalProps({ + open: true, + title: t('policy.modal.title.inform'), + action: t('policy.table.actions.inform'), + processing: t('policy.table.actions.informing'), + items: [item], + emptyState: undefined, // there is always 1 item supplied + description: t('policy.modal.message.inform'), + columns: bulkModalRemediationColumns, + keyFn: (item: PolicyTableItem) => item.policy.metadata.uid as string, + actionFn: (item) => { + return patchResource( + { + apiVersion: PolicyApiVersion, + kind: PolicyKind, + metadata: { + name: item.policy.metadata.name, + namespace: item.policy.metadata.namespace, + }, + } as Policy, + [{ op: 'replace', path: '/spec/remediationAction', value: 'inform' }] + ) + }, + close: () => { + setModalProps({ open: false }) }, - } as Policy, - [{ op: 'replace', path: '/spec/remediationAction', value: 'enforce' }] - ) + hasExternalResources: item.source !== 'Local', + }) + } }, - close: () => { - setModalProps({ open: false }) + rbac: + policyRemediationAction === 'inform' + ? undefined + : [rbacPatch(PolicyDefinition, item.policy.metadata.namespace)], + }, + { + id: 'enforce-policy', + text: t('policy.table.actions.enforce'), + tooltip: policyRemediationAction === 'enforce' ? t('Already enforcing') : t('Enforce policy'), + isSelected: policyRemediationAction === 'enforce', + click: (item: PolicyTableItem) => { + if (policyRemediationAction !== 'enforce') { + setModalProps({ + open: true, + title: t('policy.modal.title.enforce'), + action: t('policy.table.actions.enforce'), + processing: t('policy.table.actions.enforcing'), + items: [item], + emptyState: undefined, // there is always 1 item supplied + description: t('policy.modal.message.enforce'), + columns: bulkModalRemediationColumns, + keyFn: (item: PolicyTableItem) => item.policy.metadata.uid as string, + actionFn: (item) => { + return patchResource( + { + apiVersion: PolicyApiVersion, + kind: PolicyKind, + metadata: { + name: item.policy.metadata.name, + namespace: item.policy.metadata.namespace, + }, + } as Policy, + [{ op: 'replace', path: '/spec/remediationAction', value: 'enforce' }] + ) + }, + close: () => { + setModalProps({ open: false }) + }, + hasExternalResources: item.source !== 'Local', + }) + } }, - hasExternalResources: item.source !== 'Local', - }) - }, - rbac: - policyRemediationAction === 'enforce' - ? undefined - : [rbacPatch(PolicyDefinition, item.policy.metadata.namespace)], + }, + ], }, { id: 'edit-policy', diff --git a/frontend/src/routes/Infrastructure/Clusters/ManagedClusters/components/ClusterActionDropdown.tsx b/frontend/src/routes/Infrastructure/Clusters/ManagedClusters/components/ClusterActionDropdown.tsx index 87ed5a8141c..eca70cdcc47 100644 --- a/frontend/src/routes/Infrastructure/Clusters/ManagedClusters/components/ClusterActionDropdown.tsx +++ b/frontend/src/routes/Infrastructure/Clusters/ManagedClusters/components/ClusterActionDropdown.tsx @@ -1,6 +1,6 @@ /* Copyright Contributors to the Open Cluster Management project */ -import { Text, TextContent, TextVariants } from '@patternfly/react-core' +import { DropdownPosition, Text, TextContent, TextVariants } from '@patternfly/react-core' import { AcmInlineProvider } from '../../../../../ui-components' import { useContext, useMemo, useState } from 'react' import { useHistory } from 'react-router' @@ -410,6 +410,7 @@ export function ClusterActionDropdown(props: { cluster: Cluster; isKebab: boolea isKebab={props.isKebab} text={t('actions')} actions={actions} + dropdownPosition={DropdownPosition.right} /> )} <ScaleUpDialog diff --git a/frontend/src/routes/Infrastructure/Clusters/ManagedClusters/components/DownloadConfigurationDropdown.tsx b/frontend/src/routes/Infrastructure/Clusters/ManagedClusters/components/DownloadConfigurationDropdown.tsx index e99416dbac5..c215dc678a1 100644 --- a/frontend/src/routes/Infrastructure/Clusters/ManagedClusters/components/DownloadConfigurationDropdown.tsx +++ b/frontend/src/routes/Infrastructure/Clusters/ManagedClusters/components/DownloadConfigurationDropdown.tsx @@ -5,6 +5,7 @@ import { AcmDropdown } from '../../../../../ui-components' import { useContext } from 'react' import { useTranslation } from '../../../../../lib/acm-i18next' import { ClusterContext } from '../ClusterDetails/ClusterDetails' +import { DropdownPosition } from '@patternfly/react-core' export function DownloadConfigurationDropdown(props: { canGetSecret: boolean }) { const { cluster } = useContext(ClusterContext) @@ -54,6 +55,7 @@ export function DownloadConfigurationDropdown(props: { canGetSecret: boolean }) onSelect={(id: string) => downloadConfig(id)} text={t('configuration.download')} id="download-configuration" + dropdownPosition={DropdownPosition.right} /> ) } diff --git a/frontend/src/ui-components/AcmDropdown/AcmDropdown.stories.tsx b/frontend/src/ui-components/AcmDropdown/AcmDropdown.stories.tsx index a14426ce71c..5ab3629cff1 100644 --- a/frontend/src/ui-components/AcmDropdown/AcmDropdown.stories.tsx +++ b/frontend/src/ui-components/AcmDropdown/AcmDropdown.stories.tsx @@ -22,7 +22,7 @@ export const Dropdown = (args: any) => { { id: 'other-config', text: 'Other config', isAriaDisabled: true, tooltip: 'Forbidden' }, { id: 'launch-out', text: 'Launch page', icon: <ExternalLinkAltIcon /> }, { id: 'link item', text: 'Link item', href: 'www.google.com', component: 'a' }, - { id: 'new-feature', text: 'New feature', label: 'Technology Preview', labelColor: 'orange' }, + { id: 'new-feature', text: 'New feature', label: 'Technology Preview', labelColor: 'orange' as const }, ] const onSelect = (id: string) => alert(`clicked: ${id}`) return ( diff --git a/frontend/src/ui-components/AcmDropdown/AcmDropdown.tsx b/frontend/src/ui-components/AcmDropdown/AcmDropdown.tsx index 247905f979d..f4d4de7f56e 100644 --- a/frontend/src/ui-components/AcmDropdown/AcmDropdown.tsx +++ b/frontend/src/ui-components/AcmDropdown/AcmDropdown.tsx @@ -1,19 +1,24 @@ /* Copyright Contributors to the Open Cluster Management project */ -import { useState } from 'react' +import { forwardRef, useCallback, useEffect, useRef, useState } from 'react' import { - Dropdown, - DropdownToggle, - DropdownItem, DropdownPosition, - KebabToggle, Label, LabelProps, DropdownProps, TooltipPosition, + Menu, + MenuContent, + MenuItem, + Tooltip, + MenuProps, + MenuList, + Popper, + MenuToggle, } from '@patternfly/react-core' -import { makeStyles } from '@mui/styles' +import { ClassNameMap, makeStyles } from '@mui/styles' import { TooltipWrapper } from '../utils' +import { EllipsisVIcon } from '@patternfly/react-icons' type Props = Omit<DropdownProps, 'toggle' | 'onSelect' | 'dropdownItems'> @@ -47,22 +52,15 @@ export type AcmDropdownItems = { tooltipPosition?: TooltipPosition label?: string labelColor?: LabelProps['color'] + isSelected?: boolean + flyoutMenu?: AcmDropdownItems[] } const useStyles = makeStyles({ button: { '& button': { - backgroundColor: (props: AcmDropdownProps) => { - if (!props.isKebab) { - if (props.isDisabled) { - return 'var(--pf-global--disabled-color--200)' - } else if (!props.isDisabled && props.isPrimary) { - return 'var(--pf-c-dropdown__toggle--BackgroundColor)' - } else { - return 'transparent' - } - } - return undefined + '--pf-c-menu-toggle--PaddingRight': (props: AcmDropdownProps) => { + return props.isPlain ? '0' : 'var(--pf-global--spacer--sm)' }, '& span': { color: (props: AcmDropdownProps) => { @@ -76,26 +74,6 @@ const useStyles = makeStyles({ return 'var(--pf-global--primary-color--100)' }, }, - '&:hover, &:focus': { - '& span': { - color: (props: AcmDropdownProps) => (props.isKebab ? undefined : 'var(--pf-global--primary-color--100)'), - }, - '& span.pf-c-dropdown__toggle-text': { - color: (props: AcmDropdownProps) => props.isPrimary && 'var(--pf-global--Color--light-100)', - }, - '& span.pf-c-dropdown__toggle-icon': { - color: (props: AcmDropdownProps) => props.isPrimary && 'var(--pf-global--Color--light-100)', - }, - }, - '& span.pf-c-dropdown__toggle-text': { - // centers dropdown text in plain dropdown button - paddingLeft: (props: AcmDropdownProps) => { - if (props.isPlain) { - return '8px' - } - return undefined - }, - }, }, }, label: { @@ -103,77 +81,166 @@ const useStyles = makeStyles({ }, }) +type MenuItemProps = { + menuItems: AcmDropdownItems[] + onSelect: MenuProps['onSelect'] + classes: ClassNameMap<'label'> +} & MenuProps + +const MenuItems = forwardRef<HTMLDivElement, MenuItemProps>((props, ref) => { + const { menuItems, onSelect, classes, ...menuProps } = props + return ( + <Menu ref={ref} onSelect={onSelect} containsFlyout={menuItems.some((mi) => mi.flyoutMenu)} {...menuProps}> + <MenuContent> + <MenuList> + {menuItems.map((item) => { + const menuItem = ( + <MenuItem + id={item.id} + key={item.id} + itemId={item.id} + component="a" + isDisabled={item.isAriaDisabled} + isSelected={item.isSelected} + flyoutMenu={ + item.flyoutMenu && item.flyoutMenu.length ? ( + <MenuItems menuItems={item.flyoutMenu} classes={classes} onSelect={onSelect} /> + ) : undefined + } + > + {item.text} + {item.label && item.labelColor && ( + <Label className={classes.label} color={item.labelColor}> + {item.label} + </Label> + )} + </MenuItem> + ) + return item.tooltip ? ( + <Tooltip key={item.id} position={item.tooltipPosition} content={item.tooltip}> + <div>{menuItem}</div> + </Tooltip> + ) : ( + menuItem + ) + })} + </MenuList> + </MenuContent> + </Menu> + ) +}) + export function AcmDropdown(props: AcmDropdownProps) { + const { + dropdownItems, + dropdownPosition, + id, + isDisabled, + isKebab, + isPlain, + isPrimary, + onHover, + onSelect, + onToggle, + text, + tooltip, + tooltipPosition, + } = props const [isOpen, setOpen] = useState<boolean>(false) + const popperContainer = useRef<HTMLDivElement>(null) + const toggleRef = useRef<HTMLButtonElement>(null) + const menuRef = useRef<HTMLDivElement>(null) const classes = useStyles(props) - const onSelect = (id: string) => { - props.onSelect(id) - setOpen(false) - const element = document.getElementById(props.id) - /* istanbul ignore else */ - if (element) element.focus() - } + const toggleMenu = useCallback(() => { + if (onToggle) { + onToggle(!isOpen) + } + setOpen(!isOpen) + }, [isOpen, onToggle]) + + const handleSelect = useCallback( + (_event?: React.MouseEvent, itemId?: string | number) => { + onSelect((itemId || '').toString()) + setOpen(false) + const element = document.getElementById(id) + /* istanbul ignore else */ + if (element) element.focus() + }, + [id, onSelect] + ) + + const handleToggleClick = useCallback(() => { + setTimeout(() => { + if (menuRef.current) { + const firstElement = menuRef.current.querySelector('li > button:not(:disabled), li > a:not(:disabled)') + firstElement && (firstElement as HTMLElement).focus() + } + }, 0) + toggleMenu() + }, [toggleMenu]) + + const handleMenuKeys = useCallback( + (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') { + toggleMenu() + toggleRef.current?.focus() + } + } + }, + [isOpen, toggleMenu] + ) + + const handleClickOutside = useCallback( + (event: MouseEvent) => { + if ( + isOpen && + !toggleRef.current?.contains(event.target as Node) && + !menuRef.current?.contains(event.target as Node) + ) { + toggleMenu() + } + }, + [isOpen, toggleMenu] + ) + + useEffect(() => { + window.addEventListener('keydown', handleMenuKeys) + window.addEventListener('click', handleClickOutside) + return () => { + window.removeEventListener('keydown', handleMenuKeys) + window.removeEventListener('click', handleClickOutside) + } + }, [handleMenuKeys, handleClickOutside]) return ( - <TooltipWrapper showTooltip={!!props.tooltip} tooltip={props.tooltip} tooltipPosition={props.tooltipPosition}> - <Dropdown - className={classes.button} - onMouseOver={props.onHover} - position={props.dropdownPosition || DropdownPosition.right} - dropdownItems={props.dropdownItems.map((item) => { - return ( - <DropdownItem - key={item.id} - tooltip={item.tooltip} - tooltipProps={{ position: item.tooltipPosition }} - href={item.href} - id={item.id} - isAriaDisabled={item.isAriaDisabled} - icon={item.icon} - onClick={() => onSelect(item.id)} - > - {item.text} - {item.label && item.labelColor && ( - <Label className={classes.label} color={item.labelColor}> - {item.label} - </Label> - )} - </DropdownItem> - ) - })} - toggle={ - props.isKebab ? ( - <KebabToggle - id={props.id} - isDisabled={props.isDisabled} - onToggle={() => { - /* istanbul ignore next */ - if (props.onToggle) { - props.onToggle(!isOpen) - } - setOpen(!isOpen) - }} - /> - ) : ( - <DropdownToggle - isPrimary={props.isPrimary} - id={props.id} - isDisabled={props.isDisabled} - onToggle={() => { - if (props.onToggle) { - props.onToggle(!isOpen) - } - setOpen(!isOpen) - }} + <TooltipWrapper showTooltip={!!tooltip} tooltip={tooltip} tooltipPosition={tooltipPosition}> + <div ref={popperContainer} className={classes.button}> + <Popper + trigger={ + <MenuToggle + ref={toggleRef} + variant={isKebab ? 'plain' : isPlain ? 'plainText' : isPrimary ? 'primary' : 'default'} + id={id} + isDisabled={isDisabled} + onClick={handleToggleClick} + onMouseOver={onHover} + isExpanded={isOpen} + aria-label={text} > - {props.text} - </DropdownToggle> - ) - } - isOpen={isOpen} - isPlain={props.isPlain} - /> + {isKebab ? <EllipsisVIcon /> : text} + </MenuToggle> + } + popperMatchesTriggerWidth={false} + isVisible={isOpen} + position={dropdownPosition || (isKebab ? DropdownPosition.right : DropdownPosition.left)} + popper={<MenuItems ref={menuRef} menuItems={dropdownItems} onSelect={handleSelect} classes={classes} />} + /> + </div> </TooltipWrapper> ) }