diff --git a/package.json b/package.json index 49f968d2..47cfc262 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-menu", - "version": "9.8.3", + "version": "9.8.4", "description": "menu ui component for react", "keywords": [ "react", diff --git a/src/MenuItem.tsx b/src/MenuItem.tsx index 468c8206..1be29261 100644 --- a/src/MenuItem.tsx +++ b/src/MenuItem.tsx @@ -1,18 +1,19 @@ -import * as React from 'react'; import classNames from 'classnames'; import Overflow from 'rc-overflow'; -import warning from 'rc-util/lib/warning'; import KeyCode from 'rc-util/lib/KeyCode'; import omit from 'rc-util/lib/omit'; -import type { MenuInfo, MenuItemType } from './interface'; +import { useComposeRef } from 'rc-util/lib/ref'; +import warning from 'rc-util/lib/warning'; +import * as React from 'react'; +import { useMenuId } from './context/IdContext'; import { MenuContext } from './context/MenuContext'; -import useActive from './hooks/useActive'; -import { warnItemProp } from './utils/warnUtil'; -import Icon from './Icon'; -import useDirectionStyle from './hooks/useDirectionStyle'; import { useFullPath, useMeasure } from './context/PathContext'; -import { useMenuId } from './context/IdContext'; import PrivateContext from './context/PrivateContext'; +import useActive from './hooks/useActive'; +import useDirectionStyle from './hooks/useDirectionStyle'; +import Icon from './Icon'; +import type { MenuInfo, MenuItemType } from './interface'; +import { warnItemProp } from './utils/warnUtil'; export interface MenuItemProps extends Omit, @@ -38,12 +39,17 @@ export interface MenuItemProps class LegacyMenuItem extends React.Component { render() { const { title, attribute, elementRef, ...restProps } = this.props; - + // Here the props are eventually passed to the DOM element. // React does not recognize non-standard attributes. // Therefore, remove the props that is not used here. // ref: https://github.com/ant-design/ant-design/issues/41395 - const passedProps = omit(restProps, ['eventKey', 'popupClassName', 'popupOffset', 'onTitleClick']); + const passedProps = omit(restProps, [ + 'eventKey', + 'popupClassName', + 'popupOffset', + 'onTitleClick', + ]); warning( !attribute, '`attribute` of Menu.Item is deprecated. Please pass attribute directly.', @@ -63,184 +69,191 @@ class LegacyMenuItem extends React.Component { /** * Real Menu Item component */ -const InternalMenuItem = (props: MenuItemProps) => { - const { - style, - className, - - eventKey, - warnKey, - disabled, - itemIcon, - children, +const InternalMenuItem = React.forwardRef( + (props: MenuItemProps, ref: React.Ref) => { + const { + style, + className, - // Aria - role, + eventKey, + warnKey, + disabled, + itemIcon, + children, - // Active - onMouseEnter, - onMouseLeave, + // Aria + role, - onClick, - onKeyDown, + // Active + onMouseEnter, + onMouseLeave, - onFocus, + onClick, + onKeyDown, - ...restProps - } = props; + onFocus, - const domDataId = useMenuId(eventKey); + ...restProps + } = props; - const { - prefixCls, - onItemClick, + const domDataId = useMenuId(eventKey); - disabled: contextDisabled, - overflowDisabled, + const { + prefixCls, + onItemClick, - // Icon - itemIcon: contextItemIcon, + disabled: contextDisabled, + overflowDisabled, - // Select - selectedKeys, + // Icon + itemIcon: contextItemIcon, - // Active - onActive, - } = React.useContext(MenuContext); + // Select + selectedKeys, - const { _internalRenderMenuItem } = React.useContext(PrivateContext); + // Active + onActive, + } = React.useContext(MenuContext); - const itemCls = `${prefixCls}-item`; + const { _internalRenderMenuItem } = React.useContext(PrivateContext); - const legacyMenuItemRef = React.useRef(); - const elementRef = React.useRef(); - const mergedDisabled = contextDisabled || disabled; + const itemCls = `${prefixCls}-item`; - const connectedKeys = useFullPath(eventKey); - - // ================================ Warn ================================ - if (process.env.NODE_ENV !== 'production' && warnKey) { - warning(false, 'MenuItem should not leave undefined `key`.'); - } + const legacyMenuItemRef = React.useRef(); + const elementRef = React.useRef(); + const mergedDisabled = contextDisabled || disabled; - // ============================= Info ============================= - const getEventInfo = ( - e: React.MouseEvent | React.KeyboardEvent, - ): MenuInfo => { - return { - key: eventKey, - // Note: For legacy code is reversed which not like other antd component - keyPath: [...connectedKeys].reverse(), - item: legacyMenuItemRef.current, - domEvent: e, - }; - }; + const mergedEleRef = useComposeRef(ref, elementRef); - // ============================= Icon ============================= - const mergedItemIcon = itemIcon || contextItemIcon; + const connectedKeys = useFullPath(eventKey); - // ============================ Active ============================ - const { active, ...activeProps } = useActive( - eventKey, - mergedDisabled, - onMouseEnter, - onMouseLeave, - ); + // ================================ Warn ================================ + if (process.env.NODE_ENV !== 'production' && warnKey) { + warning(false, 'MenuItem should not leave undefined `key`.'); + } - // ============================ Select ============================ - const selected = selectedKeys.includes(eventKey); + // ============================= Info ============================= + const getEventInfo = ( + e: React.MouseEvent | React.KeyboardEvent, + ): MenuInfo => { + return { + key: eventKey, + // Note: For legacy code is reversed which not like other antd component + keyPath: [...connectedKeys].reverse(), + item: legacyMenuItemRef.current, + domEvent: e, + }; + }; - // ======================== DirectionStyle ======================== - const directionStyle = useDirectionStyle(connectedKeys.length); + // ============================= Icon ============================= + const mergedItemIcon = itemIcon || contextItemIcon; - // ============================ Events ============================ - const onInternalClick: React.MouseEventHandler = e => { - if (mergedDisabled) { - return; - } + // ============================ Active ============================ + const { active, ...activeProps } = useActive( + eventKey, + mergedDisabled, + onMouseEnter, + onMouseLeave, + ); - const info = getEventInfo(e); + // ============================ Select ============================ + const selected = selectedKeys.includes(eventKey); - onClick?.(warnItemProp(info)); - onItemClick(info); - }; + // ======================== DirectionStyle ======================== + const directionStyle = useDirectionStyle(connectedKeys.length); - const onInternalKeyDown: React.KeyboardEventHandler = e => { - onKeyDown?.(e); + // ============================ Events ============================ + const onInternalClick: React.MouseEventHandler = e => { + if (mergedDisabled) { + return; + } - if (e.which === KeyCode.ENTER) { const info = getEventInfo(e); - // Legacy. Key will also trigger click event onClick?.(warnItemProp(info)); onItemClick(info); + }; + + const onInternalKeyDown: React.KeyboardEventHandler = e => { + onKeyDown?.(e); + + if (e.which === KeyCode.ENTER) { + const info = getEventInfo(e); + + // Legacy. Key will also trigger click event + onClick?.(warnItemProp(info)); + onItemClick(info); + } + }; + + /** + * Used for accessibility. Helper will focus element without key board. + * We should manually trigger an active + */ + const onInternalFocus: React.FocusEventHandler = e => { + onActive(eventKey); + onFocus?.(e); + }; + + // ============================ Render ============================ + const optionRoleProps: React.HTMLAttributes = {}; + + if (props.role === 'option') { + optionRoleProps['aria-selected'] = selected; } - }; - - /** - * Used for accessibility. Helper will focus element without key board. - * We should manually trigger an active - */ - const onInternalFocus: React.FocusEventHandler = e => { - onActive(eventKey); - onFocus?.(e); - }; - - // ============================ Render ============================ - const optionRoleProps: React.HTMLAttributes = {}; - - if (props.role === 'option') { - optionRoleProps['aria-selected'] = selected; - } - let renderNode = ( - - {children} - - - ); + className={classNames( + itemCls, + { + [`${itemCls}-active`]: active, + [`${itemCls}-selected`]: selected, + [`${itemCls}-disabled`]: mergedDisabled, + }, + className, + )} + onClick={onInternalClick} + onKeyDown={onInternalKeyDown} + onFocus={onInternalFocus} + > + {children} + + + ); - if (_internalRenderMenuItem) { - renderNode = _internalRenderMenuItem(renderNode, props, { selected }); - } + if (_internalRenderMenuItem) { + renderNode = _internalRenderMenuItem(renderNode, props, { selected }); + } - return renderNode; -}; + return renderNode; + }, +); -function MenuItem(props: MenuItemProps): React.ReactElement { +function MenuItem( + props: MenuItemProps, + ref: React.Ref, +): React.ReactElement { const { eventKey } = props; // ==================== Record KeyPath ==================== @@ -263,7 +276,7 @@ function MenuItem(props: MenuItemProps): React.ReactElement { } // ======================== Render ======================== - return ; + return ; } -export default MenuItem; +export default React.forwardRef(MenuItem); diff --git a/tests/React18.spec.tsx b/tests/React18.spec.tsx index 33160caa..b505027d 100644 --- a/tests/React18.spec.tsx +++ b/tests/React18.spec.tsx @@ -1,8 +1,8 @@ /* eslint-disable no-undef */ -import React from 'react'; import { act, render } from '@testing-library/react'; -import Menu, { MenuItem, SubMenu } from '../src'; +import React from 'react'; import type { MenuProps } from '../src'; +import Menu, { MenuItem, SubMenu } from '../src'; describe('React18', () => { function runAllTimer() { diff --git a/tests/Responsive.spec.tsx b/tests/Responsive.spec.tsx index 1deea663..ba443cfa 100644 --- a/tests/Responsive.spec.tsx +++ b/tests/Responsive.spec.tsx @@ -1,5 +1,6 @@ /* eslint-disable no-undef, react/no-multi-comp, react/jsx-curly-brace-presence, max-classes-per-file */ import { fireEvent, render } from '@testing-library/react'; +import ResizeObserver from 'rc-resize-observer'; import KeyCode from 'rc-util/lib/KeyCode'; import React from 'react'; import { act } from 'react-dom/test-utils'; @@ -9,14 +10,14 @@ import { last } from './util'; import { spyElementPrototype } from 'rc-util/lib/test/domHook'; jest.mock('rc-resize-observer', () => { - const react = require('react'); - let ResizeObserver = jest.requireActual('rc-resize-observer'); - ResizeObserver = ResizeObserver.default || ResizeObserver; + const R = require('react'); + let RO = jest.requireActual('rc-resize-observer'); + RO = RO.default || RO; let guid = 0; - return react.forwardRef((props, ref) => { - const [id] = react.useState(() => { + return R.forwardRef((props, ref) => { + const [id] = R.useState(() => { guid += 1; return guid; }); @@ -24,7 +25,7 @@ jest.mock('rc-resize-observer', () => { global.resizeProps = global.resizeProps || new Map(); global.resizeProps.set(id, props); - return react.createElement(ResizeObserver, { ref, ...props }); + return R.createElement(RO, { ref, ...props }); }); }); @@ -43,6 +44,37 @@ describe('Menu.Responsive', () => { return Array.from(global.resizeProps!.values()); } + // MenuItem should support `ref` since HOC may use this + // https://github.com/ant-design/ant-design/issues/41570 + it('StrictMode warning', async () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const MyItem = (props: any) => { + const domRef = React.useRef(); + + return ( + {}} ref={domRef}> + + + ); + }; + + render( + + + Good + + , + ); + + act(() => { + jest.runAllTimers(); + }); + + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + it('ssr render full', () => { const { container } = render(