Repository: opossum-tool/OpossumUI
Pull request #2479 (2024-01-09 12:59:21)

chore: extract multi-select menu
### Summary of changes

- extract from auditing options the multi-select menu in order to re-use it later in other components

### Context and reason for change

work towards #2466

### How can the changes be tested

See that auditing options still work correctly.


Patch:
diff --git a/src/Frontend/Components/AttributionColumn/AuditingOptions.tsx b/src/Frontend/Components/AttributionColumn/AuditingOptions.tsx
index 0cf3a994..9f9e2ccb 100644
--- a/src/Frontend/Components/AttributionColumn/AuditingOptions.tsx
+++ b/src/Frontend/Components/AttributionColumn/AuditingOptions.tsx
@@ -3,40 +3,19 @@
 //
 // SPDX-License-Identifier: Apache-2.0
 import AddIcon from '@mui/icons-material/Add';
-import CheckIcon from '@mui/icons-material/Check';
 import MuiBox from '@mui/material/Box';
 import MuiChip from '@mui/material/Chip';
-import ListItemIcon from '@mui/material/ListItemIcon';
-import MuiListItemText from '@mui/material/ListItemText';
-import MuiMenu from '@mui/material/Menu';
-import MuiMenuItem from '@mui/material/MenuItem';
 import { SxProps } from '@mui/system';
 import { useState } from 'react';
 
 import { DisplayPackageInfo } from '../../../shared/shared-types';
 import { text } from '../../../shared/text';
 import { baseIcon } from '../../shared-styles';
+import { SelectMenu } from '../SelectMenu/SelectMenu';
 import { useAuditingOptions } from './AuditingOptions.util';
 
 const classes = {
   container: { display: 'flex', gap: '8px', flexWrap: 'wrap' },
-  menuPaper: {
-    overflow: 'visible',
-    filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
-    mt: 1.5,
-    '&:before': {
-      content: '""',
-      display: 'block',
-      position: 'absolute',
-      top: 0,
-      left: '50%',
-      width: 10,
-      height: 10,
-      bgcolor: 'background.paper',
-      transform: 'translateY(-50%) rotate(45deg)',
-      zIndex: 0,
-    },
-  },
 } satisfies SxProps;
 
 interface Props {
@@ -45,117 +24,60 @@ interface Props {
 }
 
 export function AuditingOptions({ packageInfo, isEditable }: Props) {
-  const chips = useAuditingOptions({ packageInfo, isEditable });
+  const options = useAuditingOptions({ packageInfo, isEditable });
   const [anchorEl, setAnchorEl] = useState<HTMLElement>();
-  const [pendingOptions, setPendingOptions] = useState<Array<string>>([]);
-  const inactiveChips = chips.filter(
-    ({ active, interactive }) => !active && interactive,
+  const unselectedOptions = options.filter(
+    ({ selected, interactive }) => !selected && interactive,
   );
 
-  return chips.length ? (
+  return options.length ? (
     <>
       <MuiBox sx={classes.container}>
         {renderTriggerButton()}
-        {renderActiveChips()}
+        {renderSelectedOptions()}
       </MuiBox>
-      {renderMenu()}
+      <SelectMenu
+        anchorEl={anchorEl}
+        hideSelected
+        setAnchorEl={setAnchorEl}
+        options={options.filter(({ interactive }) => interactive)}
+        sx={{ marginTop: '8px' }}
+      />
     </>
   ) : null;
 
   function renderTriggerButton() {
     return (
       isEditable &&
-      !!inactiveChips.length && (
+      !!unselectedOptions.length && (
         <MuiChip
           label={text.auditingOptions.add}
           color={'primary'}
           icon={<AddIcon color="primary" sx={baseIcon} />}
           size={'small'}
           onClick={(event) => setAnchorEl(event.currentTarget)}
-          aria-controls={anchorEl ? 'attribution-options-menu' : undefined}
-          aria-haspopup={'true'}
-          aria-expanded={anchorEl ? 'true' : undefined}
         />
       )
     );
   }
 
-  function renderActiveChips() {
-    return chips.map(
+  function renderSelectedOptions() {
+    return options.map(
       (
-        { label, icon, deleteIcon, active, onDelete, interactive, option },
+        { label, icon, deleteIcon, selected, onDelete, interactive, id },
         index,
       ) =>
-        active ? (
+        selected ? (
           <MuiChip
             key={index}
             label={label}
             size={'small'}
             icon={icon}
             onDelete={interactive ? onDelete : undefined}
-            data-testid={`auditing-option-${option}`}
+            data-testid={`auditing-option-${id}`}
             deleteIcon={deleteIcon}
           />
         ) : null,
     );
   }
-
-  function renderMenu() {
-    return (
-      <MuiMenu
-        anchorEl={anchorEl}
-        id={'attribution-options-menu'}
-        open={!!anchorEl}
-        onClose={() => {
-          setAnchorEl(undefined);
-          pendingOptions.forEach((option) => {
-            chips.find((chip) => chip.option === option)?.onAdd?.();
-          });
-          setPendingOptions([]);
-        }}
-        slotProps={{ paper: { elevation: 0, sx: classes.menuPaper } }}
-        transformOrigin={{ horizontal: 'center', vertical: 'top' }}
-        anchorOrigin={{ horizontal: 'center', vertical: 'bottom' }}
-        MenuListProps={{ variant: 'menu', sx: { padding: 0 } }}
-      >
-        {renderInactiveChips()}
-      </MuiMenu>
-    );
-
-    function renderInactiveChips() {
-      return inactiveChips.map(({ label, icon, option }, index) => (
-        <MuiMenuItem
-          sx={{ padding: '12px' }}
-          key={index}
-          onClick={() => {
-            option &&
-              setPendingOptions((prev) =>
-                prev.includes(option)
-                  ? prev.filter((value) => value !== option)
-                  : prev.concat(option),
-              );
-          }}
-          divider={index + 1 !== inactiveChips.length}
-          disableRipple
-        >
-          <ListItemIcon>{icon}</ListItemIcon>
-          <MuiListItemText
-            primary={label}
-            primaryTypographyProps={{ sx: { marginTop: '2px' } }}
-          />
-          <CheckIcon
-            sx={{
-              width: '20px',
-              height: '20px',
-              marginLeft: '16px',
-              visibility:
-                option && pendingOptions.includes(option)
-                  ? undefined
-                  : 'hidden',
-            }}
-          />
-        </MuiMenuItem>
-      ));
-    }
-  }
 }
diff --git a/src/Frontend/Components/AttributionColumn/AuditingOptions.util.tsx b/src/Frontend/Components/AttributionColumn/AuditingOptions.util.tsx
index 093f5eed..705dbbcf 100644
--- a/src/Frontend/Components/AttributionColumn/AuditingOptions.util.tsx
+++ b/src/Frontend/Components/AttributionColumn/AuditingOptions.util.tsx
@@ -38,16 +38,11 @@ import {
   SourceIcon,
   WasPreferredIcon,
 } from '../Icons/Icons';
+import { SelectMenuOption } from '../SelectMenu/SelectMenu';
 
-interface AuditingOption {
-  active: boolean;
-  option: string;
-  icon?: React.ReactElement;
+interface AuditingOption extends SelectMenuOption {
   deleteIcon?: React.ReactElement;
   interactive: boolean;
-  label: React.ReactNode;
-  onAdd?(): void;
-  onDelete?(): void;
 }
 
 export function useAuditingOptions({
@@ -111,10 +106,10 @@ export function useAuditingOptions({
   return useMemo<Array<AuditingOption>>(
     () => [
       {
-        option: 'preferred',
+        id: 'preferred',
         label: text.auditingOptions.currentlyPreferred,
         icon: <PreferredIcon noTooltip />,
-        active: !!packageInfo.preferred,
+        selected: !!packageInfo.preferred,
         onAdd: () =>
           dispatch(
             setTemporaryDisplayPackageInfo({
@@ -132,17 +127,17 @@ export function useAuditingOptions({
         interactive: isPreferenceFeatureEnabled && qaMode,
       },
       {
-        option: 'was-preferred',
+        id: 'was-preferred',
         label: text.auditingOptions.previouslyPreferred,
         icon: <WasPreferredIcon noTooltip />,
-        active: !!packageInfo.wasPreferred,
+        selected: !!packageInfo.wasPreferred,
         interactive: false,
       },
       {
-        option: 'is-modified-preferred',
+        id: 'is-modified-preferred',
         label: text.auditingOptions.modifiedPreferred,
         icon: <ModifiedPreferredIcon noTooltip />,
-        active: !!originalPreferred,
+        selected: !!originalPreferred,
         interactive: !!originalPreferred,
         deleteIcon: <ReplayIcon aria-label={'undo modified preferred'} />,
         onDelete: originalPreferred
@@ -158,17 +153,17 @@ export function useAuditingOptions({
           : undefined,
       },
       {
-        option: 'pre-selected',
+        id: 'pre-selected',
         label: text.auditingOptions.preselected,
         icon: <PreSelectedIcon noTooltip />,
-        active: !!packageInfo.preSelected,
+        selected: !!packageInfo.preSelected,
         interactive: false,
       },
       {
-        option: 'follow-up',
+        id: 'follow-up',
         label: text.auditingOptions.followUp,
         icon: <FollowUpIcon noTooltip />,
-        active: !!packageInfo.followUp,
+        selected: !!packageInfo.followUp,
         onAdd: () =>
           dispatch(
             setTemporaryDisplayPackageInfo({
@@ -186,10 +181,10 @@ export function useAuditingOptions({
         interactive: true,
       },
       {
-        option: 'needs-review',
+        id: 'needs-review',
         label: text.auditingOptions.needsReview,
         icon: <NeedsReviewIcon noTooltip />,
-        active: !!packageInfo.needsReview,
+        selected: !!packageInfo.needsReview,
         onAdd: () =>
           dispatch(
             setTemporaryDisplayPackageInfo({
@@ -207,10 +202,10 @@ export function useAuditingOptions({
         interactive: true,
       },
       {
-        option: 'excluded-from-notice',
+        id: 'excluded-from-notice',
         label: text.auditingOptions.excludedFromNotice,
         icon: <ExcludeFromNoticeIcon noTooltip />,
-        active: !!packageInfo.excludeFromNotice,
+        selected: !!packageInfo.excludeFromNotice,
         onAdd: () =>
           dispatch(
             setTemporaryDisplayPackageInfo({
@@ -228,16 +223,16 @@ export function useAuditingOptions({
         interactive: true,
       },
       {
-        option: 'source',
+        id: 'source',
         label: `${
           source.fromOrigin ? text.attributionColumn.originallyFrom : ''
         }${prettifySource(source.sourceName, attributionSources)}`,
         icon: <SourceIcon noTooltip />,
-        active: !!source.sourceName,
+        selected: !!source.sourceName,
         interactive: false,
       },
       {
-        option: 'confidence',
+        id: 'confidence',
         label: text.auditingOptions.confidence,
         icon: (
           <MuiRating
@@ -274,7 +269,7 @@ export function useAuditingOptions({
             highlightSelectedOnly
           />
         ),
-        active: true,
+        selected: true,
         interactive: false,
       },
     ],
diff --git a/src/Frontend/Components/SelectMenu/SelectMenu.style.tsx b/src/Frontend/Components/SelectMenu/SelectMenu.style.tsx
new file mode 100644
index 00000000..55ea5bbd
--- /dev/null
+++ b/src/Frontend/Components/SelectMenu/SelectMenu.style.tsx
@@ -0,0 +1,73 @@
+// SPDX-FileCopyrightText: Meta Platforms, Inc. and its affiliates
+// SPDX-FileCopyrightText: TNG Technology Consulting GmbH <https://www.tngtech.com>
+//
+// SPDX-License-Identifier: Apache-2.0
+import CheckIcon from '@mui/icons-material/Check';
+import MuiMenu, { MenuProps as MuiMenuProps } from '@mui/material/Menu';
+import MuiMenuItem from '@mui/material/MenuItem';
+import { styled } from '@mui/material/styles';
+
+import { shouldNotBeCalled } from '../../util/should-not-be-called';
+
+export const StyledMenu = styled(
+  ({
+    horizontal,
+    sx,
+    ...props
+  }: MuiMenuProps & { horizontal: 'left' | 'right' | 'center' }) => (
+    <MuiMenu
+      elevation={0}
+      transformOrigin={{ horizontal, vertical: 'top' }}
+      anchorOrigin={{ horizontal, vertical: 'bottom' }}
+      MenuListProps={{ variant: 'menu', sx: { padding: 0 } }}
+      slotProps={{
+        paper: {
+          elevation: 0,
+          sx: {
+            overflow: 'visible',
+            filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
+            marginTop: '4px',
+            '&:before': {
+              content: '""',
+              display: 'block',
+              position: 'absolute',
+              top: 0,
+              left: (() => {
+                switch (horizontal) {
+                  case 'left':
+                    return '24px';
+                  case 'right':
+                    return 'calc(100% - 24px)';
+                  case 'center':
+                    return '50%';
+                  default:
+                    return shouldNotBeCalled(horizontal);
+                }
+              })(),
+              width: 10,
+              height: 10,
+              bgcolor: 'background.paper',
+              transform: 'translateY(-50%) rotate(45deg)',
+              zIndex: 0,
+            },
+            ...sx,
+          },
+        },
+      }}
+      {...props}
+    />
+  ),
+)({});
+
+export const StyledMenuItem = styled(MuiMenuItem)({
+  padding: '8px',
+});
+
+export const StyledCheckIcon = styled(CheckIcon)<{
+  visibility: 'hidden' | 'visible';
+}>(({ visibility }) => ({
+  width: '20px',
+  height: '20px',
+  marginLeft: '16px',
+  visibility,
+}));
diff --git a/src/Frontend/Components/SelectMenu/SelectMenu.tsx b/src/Frontend/Components/SelectMenu/SelectMenu.tsx
new file mode 100644
index 00000000..3573a115
--- /dev/null
+++ b/src/Frontend/Components/SelectMenu/SelectMenu.tsx
@@ -0,0 +1,123 @@
+// SPDX-FileCopyrightText: Meta Platforms, Inc. and its affiliates
+// SPDX-FileCopyrightText: TNG Technology Consulting GmbH <https://www.tngtech.com>
+//
+// SPDX-License-Identifier: Apache-2.0
+import MuiListItemIcon from '@mui/material/ListItemIcon';
+import MuiListItemText from '@mui/material/ListItemText';
+import { SxProps } from '@mui/system';
+import { useMemo, useState } from 'react';
+
+import {
+  StyledCheckIcon,
+  StyledMenu,
+  StyledMenuItem,
+} from './SelectMenu.style';
+
+export interface SelectMenuProps {
+  anchorEl: HTMLElement | undefined;
+  hideSelected?: boolean;
+  horizontal?: 'left' | 'right' | 'center';
+  multiple?: boolean;
+  options: Array<SelectMenuOption>;
+  setAnchorEl: (anchorEl: HTMLElement | undefined) => void;
+  sx?: SxProps;
+}
+
+export interface SelectMenuOption {
+  selected: boolean;
+  icon?: React.ReactElement;
+  id: string;
+  label: React.ReactNode;
+  onAdd?(): void;
+  onDelete?(): void;
+}
+
+export const SelectMenu: React.FC<SelectMenuProps> = ({
+  anchorEl,
+  hideSelected,
+  horizontal = 'center',
+  multiple,
+  options,
+  setAnchorEl,
+  sx,
+}) => {
+  const [pendingOptionIds, setPendingOptionIds] = useState<Array<string>>(
+    hideSelected
+      ? []
+      : options.filter(({ selected }) => selected).map(({ id }) => id),
+  );
+  const visibleOptions = useMemo(
+    () =>
+      hideSelected ? options.filter(({ selected }) => !selected) : options,
+    [hideSelected, options],
+  );
+  const handleClose = () => {
+    setAnchorEl(undefined);
+
+    if (hideSelected) {
+      pendingOptionIds.forEach((id) => {
+        options.find((option) => option.id === id)?.onAdd?.();
+      });
+      setPendingOptionIds([]);
+    } else {
+      options.forEach(({ selected, id, onAdd, onDelete }) => {
+        if (selected && !pendingOptionIds.includes(id)) {
+          onDelete?.();
+        } else if (!selected && pendingOptionIds.includes(id)) {
+          onAdd?.();
+        }
+      });
+    }
+  };
+
+  return (
+    <StyledMenu
+      anchorEl={anchorEl}
+      open={!!anchorEl}
+      onClose={handleClose}
+      horizontal={horizontal}
+      sx={sx}
+    >
+      {renderVisibleOptions()}
+    </StyledMenu>
+  );
+
+  function renderVisibleOptions() {
+    return visibleOptions.map(({ label, icon, id, selected }, index) => (
+      <StyledMenuItem
+        aria-selected={selected}
+        key={index}
+        onClick={() => {
+          if (multiple) {
+            setPendingOptionIds((prev) =>
+              prev.includes(id)
+                ? prev.filter((value) => value !== id)
+                : prev.concat(id),
+            );
+          } else {
+            setPendingOptionIds([id]);
+            options.forEach((option) => {
+              if (option.id === id) {
+                option.onAdd?.();
+              } else {
+                option.onDelete?.();
+              }
+            });
+            setAnchorEl(undefined);
+          }
+        }}
+        divider={index + 1 !== visibleOptions.length}
+        disableRipple
+      >
+        <MuiListItemIcon>{icon}</MuiListItemIcon>
+        <MuiListItemText
+          primary={label}
+          primaryTypographyProps={{ sx: { marginTop: '3px' } }}
+        />
+        <StyledCheckIcon
+          visibility={pendingOptionIds.includes(id) ? 'visible' : 'hidden'}
+        />
+      </StyledMenuItem>
+    ));
+  }
+};


Test patch:
diff --git a/src/Frontend/Components/SelectMenu/__tests__/SelectMenu.test.tsx b/src/Frontend/Components/SelectMenu/__tests__/SelectMenu.test.tsx
new file mode 100644
index 00000000..9962ad1b
--- /dev/null
+++ b/src/Frontend/Components/SelectMenu/__tests__/SelectMenu.test.tsx
@@ -0,0 +1,209 @@
+// SPDX-FileCopyrightText: Meta Platforms, Inc. and its affiliates
+// SPDX-FileCopyrightText: TNG Technology Consulting GmbH <https://www.tngtech.com>
+//
+// SPDX-License-Identifier: Apache-2.0
+import { screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { faker } from '../../../../testing/Faker';
+import { renderComponent } from '../../../test-helpers/render';
+import { SelectMenu, SelectMenuOption } from '../SelectMenu';
+
+function createOption(props: Partial<SelectMenuOption> = {}): SelectMenuOption {
+  return {
+    id: faker.string.uuid(),
+    label: faker.company.name(),
+    selected: false,
+    ...props,
+  };
+}
+
+describe('SelectMenu', () => {
+  it('displays selected option in single mode', () => {
+    const anchorEl = document.createElement('div');
+    const setAnchorEl = jest.fn();
+    const option1 = createOption();
+    const option2 = createOption({ selected: true });
+    renderComponent(
+      <SelectMenu
+        anchorEl={anchorEl}
+        setAnchorEl={setAnchorEl}
+        options={[option1, option2]}
+      />,
+    );
+
+    expect(
+      within(
+        screen.getByRole('menuitem', { name: option1.label!.toString() }),
+      ).getByTestId('CheckIcon'),
+    ).not.toBeVisible();
+    expect(
+      within(
+        screen.getByRole('menuitem', { name: option2.label!.toString() }),
+      ).getByTestId('CheckIcon'),
+    ).toBeVisible();
+  });
+
+  it('selects new option in single mode', async () => {
+    const anchorEl = document.createElement('div');
+    const setAnchorEl = jest.fn();
+    const onAdd = jest.fn();
+    const onDelete = jest.fn();
+    const option1 = createOption({ onAdd });
+    const option2 = createOption({ selected: true, onDelete });
+    renderComponent(
+      <SelectMenu
+        anchorEl={anchorEl}
+        setAnchorEl={setAnchorEl}
+        options={[option1, option2]}
+      />,
+    );
+
+    await userEvent.click(
+      screen.getByRole('menuitem', { name: option1.label!.toString() }),
+    );
+
+    expect(
+      within(
+        screen.getByRole('menuitem', { name: option1.label!.toString() }),
+      ).getByTestId('CheckIcon'),
+    ).toBeVisible();
+    expect(
+      within(
+        screen.getByRole('menuitem', { name: option2.label!.toString() }),
+      ).getByTestId('CheckIcon'),
+    ).not.toBeVisible();
+    expect(setAnchorEl).toHaveBeenCalledTimes(1);
+    expect(setAnchorEl).toHaveBeenCalledWith(undefined);
+    expect(onAdd).toHaveBeenCalledTimes(1);
+    expect(onDelete).toHaveBeenCalledTimes(1);
+  });
+
+  it('displays selected options in multiple mode', () => {
+    const anchorEl = document.createElement('div');
+    const setAnchorEl = jest.fn();
+    const option1 = createOption();
+    const option2 = createOption({ selected: true });
+    renderComponent(
+      <SelectMenu
+        anchorEl={anchorEl}
+        setAnchorEl={setAnchorEl}
+        options={[option1, option2]}
+        multiple
+      />,
+    );
+
+    expect(
+      within(
+        screen.getByRole('menuitem', { name: option1.label!.toString() }),
+      ).getByTestId('CheckIcon'),
+    ).not.toBeVisible();
+    expect(
+      within(
+        screen.getByRole('menuitem', { name: option2.label!.toString() }),
+      ).getByTestId('CheckIcon'),
+    ).toBeVisible();
+  });
+
+  it('selects new option in multiple mode', async () => {
+    const anchorEl = document.createElement('div');
+    const setAnchorEl = jest.fn();
+    const onAdd = jest.fn();
+    const onDelete = jest.fn();
+    const option1 = createOption({ onAdd });
+    const option2 = createOption({ selected: true, onDelete });
+    renderComponent(
+      <SelectMenu
+        anchorEl={anchorEl}
+        setAnchorEl={setAnchorEl}
+        options={[option1, option2]}
+        multiple
+      />,
+    );
+
+    await userEvent.click(
+      screen.getByRole('menuitem', { name: option1.label!.toString() }),
+    );
+    await userEvent.click(
+      screen.getByRole('menuitem', { name: option2.label!.toString() }),
+    );
+
+    expect(
+      within(
+        screen.getByRole('menuitem', { name: option1.label!.toString() }),
+      ).getByTestId('CheckIcon'),
+    ).toBeVisible();
+    expect(
+      within(
+        screen.getByRole('menuitem', { name: option2.label!.toString() }),
+      ).getByTestId('CheckIcon'),
+    ).not.toBeVisible();
+    expect(setAnchorEl).not.toHaveBeenCalled();
+
+    await userEvent.keyboard('{Escape}');
+
+    expect(setAnchorEl).toHaveBeenCalledWith(undefined);
+    expect(onAdd).toHaveBeenCalledTimes(1);
+    expect(onDelete).toHaveBeenCalledTimes(1);
+  });
+
+  it('does not display selected options in multiple mode when selected are hidden', () => {
+    const anchorEl = document.createElement('div');
+    const setAnchorEl = jest.fn();
+    const option1 = createOption();
+    const option2 = createOption({ selected: true });
+    renderComponent(
+      <SelectMenu
+        anchorEl={anchorEl}
+        setAnchorEl={setAnchorEl}
+        options={[option1, option2]}
+        multiple
+        hideSelected
+      />,
+    );
+
+    expect(
+      within(
+        screen.getByRole('menuitem', { name: option1.label!.toString() }),
+      ).getByTestId('CheckIcon'),
+    ).not.toBeVisible();
+    expect(
+      screen.queryByRole('menuitem', { name: option2.label!.toString() }),
+    ).not.toBeInTheDocument();
+  });
+
+  it('selects new option in multiple mode when selected are hidden', async () => {
+    const anchorEl = document.createElement('div');
+    const setAnchorEl = jest.fn();
+    const onAdd = jest.fn();
+    const onDelete = jest.fn();
+    const option1 = createOption({ onAdd });
+    const option2 = createOption({ selected: true, onDelete });
+    renderComponent(
+      <SelectMenu
+        anchorEl={anchorEl}
+        setAnchorEl={setAnchorEl}
+        options={[option1, option2]}
+        multiple
+        hideSelected
+      />,
+    );
+
+    await userEvent.click(
+      screen.getByRole('menuitem', { name: option1.label!.toString() }),
+    );
+
+    expect(
+      within(
+        screen.getByRole('menuitem', { name: option1.label!.toString() }),
+      ).getByTestId('CheckIcon'),
+    ).toBeVisible();
+    expect(setAnchorEl).not.toHaveBeenCalled();
+
+    await userEvent.keyboard('{Escape}');
+
+    expect(setAnchorEl).toHaveBeenCalledWith(undefined);
+    expect(onAdd).toHaveBeenCalledTimes(1);
+    expect(onDelete).not.toHaveBeenCalled();
+  });
+});