diff --git a/src/Menu.tsx b/src/Menu.tsx index ef9db196..e2e86d33 100644 --- a/src/Menu.tsx +++ b/src/Menu.tsx @@ -2,16 +2,20 @@ import classNames from 'classnames'; import type { CSSMotionProps } from 'rc-motion'; import Overflow from 'rc-overflow'; import useMergedState from 'rc-util/lib/hooks/useMergedState'; +import isEqual from 'rc-util/lib/isEqual'; import warning from 'rc-util/lib/warning'; import * as React from 'react'; import { useImperativeHandle } from 'react'; import { flushSync } from 'react-dom'; -import isEqual from 'rc-util/lib/isEqual'; -import { getMenuId, IdContext } from './context/IdContext'; +import { IdContext } from './context/IdContext'; import MenuContextProvider from './context/MenuContext'; import { PathRegisterContext, PathUserContext } from './context/PathContext'; import PrivateContext from './context/PrivateContext'; -import useAccessibility from './hooks/useAccessibility'; +import { + getFocusableElements, + refreshElements, + useAccessibility, +} from './hooks/useAccessibility'; import useKeyRecords, { OVERFLOW_KEY } from './hooks/useKeyRecords'; import useMemoCallback from './hooks/useMemoCallback'; import useUUID from './hooks/useUUID'; @@ -270,8 +274,9 @@ const Menu = React.forwardRef((props, ref) => { }; // >>>>> Cache & Reset open keys when inlineCollapsed changed - const [inlineCacheOpenKeys, setInlineCacheOpenKeys] = - React.useState(mergedOpenKeys); + const [inlineCacheOpenKeys, setInlineCacheOpenKeys] = React.useState( + mergedOpenKeys, + ); const mountRef = React.useRef(false); @@ -347,10 +352,9 @@ const Menu = React.forwardRef((props, ref) => { [registerPath, unregisterPath], ); - const pathUserContext = React.useMemo( - () => ({ isSubPathKey }), - [isSubPathKey], - ); + const pathUserContext = React.useMemo(() => ({ isSubPathKey }), [ + isSubPathKey, + ]); React.useEffect(() => { refreshOverflowKeys( @@ -378,20 +382,31 @@ const Menu = React.forwardRef((props, ref) => { setMergedActiveKey(undefined); }); - useImperativeHandle(ref, () => ({ - list: containerRef.current, - focus: options => { - const shouldFocusKey = - mergedActiveKey ?? childList.find(node => !node.props.disabled)?.key; - if (shouldFocusKey) { - containerRef.current - ?.querySelector( - `li[data-menu-id='${getMenuId(uuid, shouldFocusKey as string)}']`, - ) - ?.focus?.(options); - } - }, - })); + useImperativeHandle(ref, () => { + return { + list: containerRef.current, + focus: options => { + const keys = getKeys(); + const { elements, key2element, element2key } = refreshElements( + keys, + uuid, + ); + const focusableElements = getFocusableElements( + containerRef.current, + elements, + ); + + const shouldFocusKey = + mergedActiveKey ?? element2key.get(focusableElements[0]); + + const elementToFocus = key2element.get(shouldFocusKey); + + if (shouldFocusKey && elementToFocus) { + elementToFocus?.focus?.(options); + } + }, + }; + }); // ======================== Select ======================== // >>>>> Select keys diff --git a/src/hooks/useAccessibility.ts b/src/hooks/useAccessibility.ts index b60b27bf..5cd58e20 100644 --- a/src/hooks/useAccessibility.ts +++ b/src/hooks/useAccessibility.ts @@ -1,9 +1,9 @@ -import * as React from 'react'; +import { getFocusNodeList } from 'rc-util/lib/Dom/focus'; import KeyCode from 'rc-util/lib/KeyCode'; import raf from 'rc-util/lib/raf'; -import { getFocusNodeList } from 'rc-util/lib/Dom/focus'; -import type { MenuMode } from '../interface'; +import * as React from 'react'; import { getMenuId } from '../context/IdContext'; +import type { MenuMode } from '../interface'; // destruct to reduce minify size const { LEFT, RIGHT, UP, DOWN, ENTER, ESC, HOME, END } = KeyCode; @@ -134,7 +134,7 @@ function getFocusElement( /** * Get focusable elements from the element set under provided container */ -function getFocusableElements( +export function getFocusableElements( container: HTMLElement, elements: Set, ) { @@ -181,7 +181,27 @@ function getNextFocusElement( return sameLevelFocusableMenuElementList[focusIndex]; } -export default function useAccessibility( +export const refreshElements = (keys: string[], id: string) => { + const elements = new Set(); + const key2element = new Map(); + const element2key = new Map(); + + keys.forEach(key => { + const element = document.querySelector( + `[data-menu-id='${getMenuId(id, key)}']`, + ) as HTMLElement; + + if (element) { + elements.add(element); + element2key.set(element, key); + key2element.set(key, element); + } + }); + + return { elements, key2element, element2key }; +}; + +export function useAccessibility( mode: MenuMode, activeKey: string, isRtl: boolean, @@ -216,35 +236,10 @@ export default function useAccessibility( const { which } = e; if ([...ArrowKeys, ENTER, ESC, HOME, END].includes(which)) { - // Convert key to elements - let elements: Set; - let key2element: Map; - let element2key: Map; - - // >>> Wrap as function since we use raf for some case - const refreshElements = () => { - elements = new Set(); - key2element = new Map(); - element2key = new Map(); - - const keys = getKeys(); - - keys.forEach(key => { - const element = document.querySelector( - `[data-menu-id='${getMenuId(id, key)}']`, - ) as HTMLElement; - - if (element) { - elements.add(element); - element2key.set(element, key); - key2element.set(key, element); - } - }); + const keys = getKeys(); - return elements; - }; - - refreshElements(); + let refreshedElements = refreshElements(keys, id); + const { elements, key2element, element2key } = refreshedElements; // First we should find current focused MenuItem/SubMenu element const activeElement = key2element.get(activeKey); @@ -341,7 +336,7 @@ export default function useAccessibility( cleanRaf(); rafRef.current = raf(() => { // Async should resync elements - refreshElements(); + refreshedElements = refreshElements(keys, id); const controlId = focusMenuElement.getAttribute('aria-controls'); const subQueryContainer = document.getElementById(controlId); @@ -349,7 +344,7 @@ export default function useAccessibility( // Get sub focusable menu item const targetElement = getNextFocusElement( subQueryContainer, - elements, + refreshedElements.elements, ); // Focus menu item diff --git a/tests/Focus.spec.tsx b/tests/Focus.spec.tsx index f3f94216..5457895f 100644 --- a/tests/Focus.spec.tsx +++ b/tests/Focus.spec.tsx @@ -1,9 +1,20 @@ /* eslint-disable no-undef */ -import { fireEvent, render } from '@testing-library/react'; +import { act, fireEvent, render } from '@testing-library/react'; +import { spyElementPrototypes } from 'rc-util/lib/test/domHook'; import React from 'react'; -import Menu, { MenuItem, SubMenu } from '../src'; +import Menu, { MenuItem, MenuItemGroup, MenuRef, SubMenu } from '../src'; describe('Focus', () => { + beforeAll(() => { + // Mock to force make menu item visible + spyElementPrototypes(HTMLElement, { + offsetParent: { + get() { + return this.parentElement; + }, + }, + }); + }); beforeEach(() => { global.triggerProps = null; @@ -15,13 +26,15 @@ describe('Focus', () => { jest.useRealTimers(); }); - it('Get focus', () => { - const { container } = render( - - - 1 - - , + it('Get focus', async () => { + const { container } = await act(async () => + render( + + + 1 + + , + ), ); // Item focus @@ -34,5 +47,149 @@ describe('Focus', () => { fireEvent.focus(container.querySelector('.rc-menu-submenu-title')); expect(container.querySelector('.rc-menu-submenu-active')).toBeTruthy(); }); + + it('should support focus through ref', async () => { + const menuRef = React.createRef(); + const { getByTestId } = await act(async () => + render( + + + Disabled child + + + Light + + , + ), + ); + + act(() => menuRef.current.focus()); + + const firstFocusableItem = getByTestId('first-focusable'); + expect(document.activeElement).toBe(firstFocusableItem); + expect(firstFocusableItem).toHaveClass('rc-menu-item-active'); + }); + + it('should focus active item through ref', async () => { + const menuRef = React.createRef(); + const { getByTestId } = await act(async () => + render( + + Light + + Cat + + , + ), + ); + act(() => menuRef.current.focus()); + + const activeKey = getByTestId('active-key'); + expect(document.activeElement).toBe(activeKey); + expect(activeKey).toHaveClass('rc-menu-item-active'); + }); + + it('focus moves to the next accessible menu item if the first child is empty group', async () => { + const menuRef = React.createRef(); + const { getByTestId } = await act(async () => + render( + + + + Disabled child + + + Light + + , + ), + ); + + act(() => menuRef.current.focus()); + + const firstFocusableItem = getByTestId('first-focusable'); + expect(document.activeElement).toBe(firstFocusableItem); + expect(firstFocusableItem).toHaveClass('rc-menu-item-active'); + }); + + it('focus moves to the next accessible group item if the first child is non-empty group', async () => { + const menuRef = React.createRef(); + const { getByTestId } = await act(async () => + render( + + + + group-child-1 + + + group-child-2 + + + Light + , + ), + ); + + act(() => menuRef.current.focus()); + + const firstFocusableItem = getByTestId('first-focusable'); + expect(document.activeElement).toBe(firstFocusableItem); + expect(firstFocusableItem).toHaveClass('rc-menu-item-active'); + }); + + it('focus moves to nested group item correctly', async () => { + const menuRef = React.createRef(); + const { getByTestId } = await act(async () => + render( + + + + group-child-1 + + + + nested-group-child-1 + + + nested-group-child-2 + + + group-child-3 + + , + ), + ); + + act(() => menuRef.current.focus()); + + const firstFocusableItem = getByTestId('first-focusable'); + expect(document.activeElement).toBe(firstFocusableItem); + expect(firstFocusableItem).toHaveClass('rc-menu-item-active'); + }); + + it('focus moves to submenu correctly', async () => { + const menuRef = React.createRef(); + const { getByTestId, getByTitle } = await act(async () => + render( + + + Disabled child + + + Submenu child + + Light + , + ), + ); + + act(() => menuRef.current.focus()); + + expect(document.activeElement).toBe(getByTitle('Submenu')); + expect(getByTestId('sub-menu')).toHaveClass('rc-menu-submenu-active'); + }); }); /* eslint-enable */ diff --git a/tests/Menu.spec.tsx b/tests/Menu.spec.tsx index 194a1ff5..a4d35c78 100644 --- a/tests/Menu.spec.tsx +++ b/tests/Menu.spec.tsx @@ -1,4 +1,5 @@ /* eslint-disable no-undef, react/no-multi-comp, react/jsx-curly-brace-presence, max-classes-per-file */ +import type { MenuMode } from '@/interface'; import { fireEvent, render } from '@testing-library/react'; import KeyCode from 'rc-util/lib/KeyCode'; import { resetWarned } from 'rc-util/lib/warning'; @@ -7,7 +8,6 @@ import { act } from 'react-dom/test-utils'; import type { MenuRef } from '../src'; import Menu, { Divider, MenuItem, MenuItemGroup, SubMenu } from '../src'; import { isActive, last } from './util'; -import type { MenuMode } from '@/interface'; jest.mock('@rc-component/trigger', () => { const react = require('react'); @@ -285,7 +285,7 @@ describe('Menu', () => { // don't use selectedKeys as string // it is a compatible feature for https://github.com/ant-design/ant-design/issues/29429 const { container } = render( - + 1 2 , @@ -329,8 +329,8 @@ describe('Menu', () => { it('openKeys should allow to be empty', () => { const { container } = render( { }} - onOpenChange={() => { }} + onClick={() => {}} + onOpenChange={() => {}} openKeys={undefined} selectedKeys={['1']} mode="inline" @@ -703,34 +703,6 @@ describe('Menu', () => { expect(menuRef.current?.list).toBe(container.querySelector('ul')); }); - it('should support focus through ref', () => { - const menuRef = React.createRef(); - const { container } = render( - - - Disabled child - - Light - , - ); - menuRef.current?.focus(); - - expect(document.activeElement).toBe(last(container.querySelectorAll('li'))); - }); - - it('should focus active item through ref', () => { - const menuRef = React.createRef(); - const { container } = render( - - Light - Cat - , - ); - menuRef.current?.focus(); - - expect(document.activeElement).toBe(last(container.querySelectorAll('li'))); - }); - it('should render a divider with role="separator"', () => { const menuRef = React.createRef(); const { container } = render( @@ -747,52 +719,43 @@ describe('Menu', () => { expect(divider).toHaveAttribute('role', 'separator'); }); it('expandIcon should be hidden when setting null or false', () => { - const App = ({expand, subExpand}: {expand?: React.ReactNode, subExpand?: React.ReactNode}) => ( + const App = ({ + expand, + subExpand, + }: { + expand?: React.ReactNode; + subExpand?: React.ReactNode; + }) => ( - + 0-1 0-2 - , - + + , + 0-1 0-2 - , - Cat + + ,Cat ); - + const { container, rerender } = render( , ); - expect(container.querySelectorAll(".rc-menu-submenu-arrow").length).toBe(0); - - rerender( - , - ); - expect(container.querySelectorAll(".rc-menu-submenu-arrow").length).toBe(1); + expect(container.querySelectorAll('.rc-menu-submenu-arrow').length).toBe(0); - rerender( - , - ); - expect(container.querySelectorAll(".rc-menu-submenu-arrow").length).toBe(2); + rerender(); + expect(container.querySelectorAll('.rc-menu-submenu-arrow').length).toBe(1); - rerender( - , - ); - expect(container.querySelectorAll(".rc-menu-submenu-arrow").length).toBe(0); + rerender(); + expect(container.querySelectorAll('.rc-menu-submenu-arrow').length).toBe(2); - rerender( - , - ); - expect(container.querySelectorAll(".rc-menu-submenu-arrow").length).toBe(1); + rerender(); + expect(container.querySelectorAll('.rc-menu-submenu-arrow').length).toBe(0); + rerender(); + expect(container.querySelectorAll('.rc-menu-submenu-arrow').length).toBe(1); }); }); /* eslint-enable */