From 1771cb6c8ea67f33ac68f67722ee52b52597f33d Mon Sep 17 00:00:00 2001 From: Kilian Collender <37899503+Kilian-Collender@users.noreply.github.com> Date: Wed, 15 May 2024 16:25:56 +0100 Subject: [PATCH] fix(menu): include leave intent delay for sub menus (#16466) * fix(menu): protect access to child refs when looking for activeElement * fix(menu): include leave delay to avoid accidental closure of sub menu The sub menu can accidentally trigger a premature closure of the sub menu in the following cases: - the mouse moves across and down as the user selects a lower sub menu, causing the mouse to leave the menuItem. - the menu has a scroll bar, as the mouse crosses this to enter the sub menu it triggers the closure before the user can access the sub menu. Including a delay in the closure allows for the users mouse to reenter the submenu and cancel the closure of the menu. * chore(contributor): add Kilian-Collender as contributor * fix(menu): clean up test aria-label * feat(menu): add aria-label option for menu items * fix(menu): remove aria-label to make AVT tests happy --- .all-contributorsrc | 9 ++ README.md | 1 + .../react/src/components/Menu/Menu-test.js | 84 ++++++++++++++++++- packages/react/src/components/Menu/Menu.tsx | 2 +- .../react/src/components/Menu/MenuItem.tsx | 18 +++- 5 files changed, 110 insertions(+), 4 deletions(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 498dc98e1004..7c2332da4168 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1524,6 +1524,15 @@ "contributions": [ "code" ] + }, + { + "login": "Kilian-Collender", + "name": "Kilian Collender", + "avatar_url": "https://avatars.githubusercontent.com/u/37899503?v=4", + "profile": "https://github.com/Kilian-Collender", + "contributions": [ + "code" + ] } ], "commitConvention": "none" diff --git a/README.md b/README.md index 747a79c89137..ee184317aaf8 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,7 @@ check out our [Contributing Guide](/.github/CONTRIBUTING.md) and our
Nikhil Tomar

💻
Anina Antony

💻
Yael Chavoya

💻 +
Kilian Collender

💻 diff --git a/packages/react/src/components/Menu/Menu-test.js b/packages/react/src/components/Menu/Menu-test.js index a9c62c60edd7..43a5630180e9 100644 --- a/packages/react/src/components/Menu/Menu-test.js +++ b/packages/react/src/components/Menu/Menu-test.js @@ -7,7 +7,7 @@ import React from 'react'; import { Menu, MenuItem, MenuItemSelectable, MenuItemRadioGroup } from './'; -import { render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; describe('Menu', () => { @@ -98,6 +98,88 @@ describe('Menu', () => { spy.mockRestore(); }); }); + + describe('Submenu behavior', () => { + beforeEach(() => { + jest.useFakeTimers(); + render( + + + + + + ); + }); + afterEach(() => { + jest.useRealTimers(); + }); + it('should only show parent and not then submenu when not hovered', () => { + const menus = screen.getAllByRole('menu'); + expect(menus.length).toBe(2); + expect(menus[0]).toHaveClass('cds--menu--open'); + expect(menus[1]).not.toHaveClass('cds--menu--open'); + }); + + it('should show sub menu when hovered for hoverIntentDelay', async () => { + const menus = screen.getAllByRole('menu'); + await act(() => + fireEvent.mouseEnter( + screen.getByRole('menuitem', { name: 'Submenu Submenu' }) + ) + ); + expect(menus[0]).toHaveClass('cds--menu--open'); + expect(menus[1]).not.toHaveClass('cds--menu--open'); + + await act(() => jest.runOnlyPendingTimers()); + expect(menus[0]).toHaveClass('cds--menu--open'); + expect(menus[1]).toHaveClass('cds--menu--open'); + }); + + it('should close sub menu on leave after leaveIntentDelay', async () => { + const menus = screen.getAllByRole('menu'); + await act(() => { + fireEvent.mouseEnter( + screen.getByRole('menuitem', { name: 'Submenu Submenu' }) + ); + jest.runOnlyPendingTimers(); + }); + expect(menus[0]).toHaveClass('cds--menu--open'); + expect(menus[1]).toHaveClass('cds--menu--open'); + + await act(() => { + fireEvent.mouseLeave( + screen.getByRole('menuitem', { name: 'Submenu Submenu' }) + ); + jest.runOnlyPendingTimers(); + }); + expect(menus[0]).toHaveClass('cds--menu--open'); + expect(menus[1]).not.toHaveClass('cds--menu--open'); + }); + + it('should cancel close sub menu on leave and reenter before leaveIntentDelay', async () => { + const menus = screen.getAllByRole('menu'); + await act(() => { + fireEvent.mouseEnter( + screen.getByRole('menuitem', { name: 'Submenu Submenu' }) + ); + jest.runOnlyPendingTimers(); + }); + expect(menus[0]).toHaveClass('cds--menu--open'); + expect(menus[1]).toHaveClass('cds--menu--open'); + + await act(() => { + fireEvent.mouseLeave( + screen.getByRole('menuitem', { name: 'Submenu Submenu' }) + ); + fireEvent.mouseEnter( + screen.getByRole('menuitem', { name: 'Submenu Submenu' }) + ); + jest.runOnlyPendingTimers(); + }); + expect(menus[0]).toHaveClass('cds--menu--open'); + expect(menus[1]).toHaveClass('cds--menu--open'); + }); + }); }); describe('MenuItem', () => { diff --git a/packages/react/src/components/Menu/Menu.tsx b/packages/react/src/components/Menu/Menu.tsx index 4d4dc86fb8f2..1e3d14f80305 100644 --- a/packages/react/src/components/Menu/Menu.tsx +++ b/packages/react/src/components/Menu/Menu.tsx @@ -234,7 +234,7 @@ const Menu = forwardRef(function Menu( function focusItem(e?: React.KeyboardEvent) { const currentItem = focusableItems.findIndex((item) => - item.ref.current.contains(document.activeElement) + item.ref?.current?.contains(document.activeElement) ); let indexToFocus = currentItem; diff --git a/packages/react/src/components/Menu/MenuItem.tsx b/packages/react/src/components/Menu/MenuItem.tsx index 295db5b8a5e1..1492a09f1283 100644 --- a/packages/react/src/components/Menu/MenuItem.tsx +++ b/packages/react/src/components/Menu/MenuItem.tsx @@ -80,6 +80,7 @@ export interface MenuItemProps extends LiHTMLAttributes { } const hoverIntentDelay = 150; // in ms +const leaveIntentDelay = 300; // in ms export const MenuItem = forwardRef( function MenuItem( @@ -113,6 +114,10 @@ export const MenuItem = forwardRef( null ); + const leaveIntentTimeout = useRef | null>( + null + ); + const isDisabled = disabled && !hasChildren; const isDanger = kind === 'danger' && !hasChildren; @@ -169,6 +174,11 @@ export const MenuItem = forwardRef( } function handleMouseEnter() { + if (leaveIntentTimeout.current) { + // When mouse reenters before closing keep sub menu open + clearTimeout(leaveIntentTimeout.current); + leaveIntentTimeout.current = null; + } hoverIntentTimeout.current = setTimeout(() => { openSubmenu(); }, hoverIntentDelay); @@ -177,8 +187,12 @@ export const MenuItem = forwardRef( function handleMouseLeave() { if (hoverIntentTimeout.current) { clearTimeout(hoverIntentTimeout.current); - closeSubmenu(); - menuItem.current?.focus(); + // Avoid closing the sub menu as soon as mouse leaves + // prevents accidental closure due to scroll bar + leaveIntentTimeout.current = setTimeout(() => { + closeSubmenu(); + menuItem.current?.focus(); + }, leaveIntentDelay); } }