diff --git a/README.md b/README.md index 16b5278f..9f29d3d8 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,12 @@ ReactDOM.render( + + ref + React.HTMLLIElement + + get dom node + className String @@ -294,6 +300,12 @@ ReactDOM.render( + + ref + React.HTMLLIElement + + get dom node + popupClassName String @@ -397,6 +409,12 @@ none + + ref + React.HTMLLIElement + + get dom node + title String|React.Element diff --git a/docs/demo/items-ref.md b/docs/demo/items-ref.md new file mode 100644 index 00000000..62e9fefa --- /dev/null +++ b/docs/demo/items-ref.md @@ -0,0 +1,3 @@ +## items-ref + + diff --git a/docs/examples/items-ref.tsx b/docs/examples/items-ref.tsx new file mode 100644 index 00000000..dc18f9f1 --- /dev/null +++ b/docs/examples/items-ref.tsx @@ -0,0 +1,100 @@ +/* eslint no-console:0 */ + +import React, { useRef } from 'react'; +import '../../assets/index.less'; +import Menu from '../../src'; + +export default () => { + const ref1 = useRef(); + const ref2 = useRef(); + const ref3 = useRef(); + const ref4 = useRef(); + const ref5 = useRef(); + const ref6 = useRef(); + const ref7 = useRef(); + + return ( + <> + + + + ); +}; diff --git a/src/MenuItem.tsx b/src/MenuItem.tsx index 1be29261..284747a3 100644 --- a/src/MenuItem.tsx +++ b/src/MenuItem.tsx @@ -16,7 +16,7 @@ import type { MenuInfo, MenuItemType } from './interface'; import { warnItemProp } from './utils/warnUtil'; export interface MenuItemProps - extends Omit, + extends Omit, Omit< React.HTMLAttributes, 'onClick' | 'onMouseEnter' | 'onMouseLeave' | 'onSelect' diff --git a/src/MenuItemGroup.tsx b/src/MenuItemGroup.tsx index f812c0b6..c41bd7f3 100644 --- a/src/MenuItemGroup.tsx +++ b/src/MenuItemGroup.tsx @@ -19,19 +19,18 @@ export interface MenuItemGroupProps warnKey?: boolean; } -const InternalMenuItemGroup = ({ - className, - title, - eventKey, - children, - ...restProps -}: MenuItemGroupProps) => { +const InternalMenuItemGroup = React.forwardRef< + HTMLLIElement, + MenuItemGroupProps +>((props, ref) => { + const { className, title, eventKey, children, ...restProps } = props; const { prefixCls } = React.useContext(MenuContext); const groupPrefixCls = `${prefixCls}-item-group`; return (
  • e.stopPropagation()} @@ -49,26 +48,32 @@ const InternalMenuItemGroup = ({
  • ); -}; +}); -export default function MenuItemGroup({ - children, - ...props -}: MenuItemGroupProps): React.ReactElement { - const connectedKeyPath = useFullPath(props.eventKey); - const childList: React.ReactElement[] = parseChildren( - children, - connectedKeyPath, - ); +const MenuItemGroup = React.forwardRef( + (props, ref) => { + const { eventKey, children } = props; + const connectedKeyPath = useFullPath(eventKey); + const childList: React.ReactElement[] = parseChildren( + children, + connectedKeyPath, + ); - const measure = useMeasure(); - if (measure) { - return (childList as any) as React.ReactElement; - } + const measure = useMeasure(); + if (measure) { + return childList as any as React.ReactElement; + } - return ( - - {childList} - - ); + return ( + + {childList} + + ); + }, +); + +if (process.env.NODE_ENV !== 'production') { + MenuItemGroup.displayName = 'MenuItemGroup'; } + +export default MenuItemGroup; diff --git a/src/SubMenu/index.tsx b/src/SubMenu/index.tsx index 06183bd2..fc962f75 100644 --- a/src/SubMenu/index.tsx +++ b/src/SubMenu/index.tsx @@ -41,213 +41,214 @@ export interface SubMenuProps // onDestroy?: DestroyEventHandler; } -const InternalSubMenu = (props: SubMenuProps) => { - const { - style, - className, +const InternalSubMenu = React.forwardRef( + (props, ref) => { + const { + style, + className, - title, - eventKey, - warnKey, + title, + eventKey, + warnKey, - disabled, - internalPopupClose, + disabled, + internalPopupClose, - children, - - // Icons - itemIcon, - expandIcon, - - // Popup - popupClassName, - popupOffset, - popupStyle, - - // Events - onClick, - onMouseEnter, - onMouseLeave, - onTitleClick, - onTitleMouseEnter, - onTitleMouseLeave, - - ...restProps - } = props; - - const domDataId = useMenuId(eventKey); + children, - const { - prefixCls, - mode, - openKeys, + // Icons + itemIcon, + expandIcon, - // Disabled - disabled: contextDisabled, - overflowDisabled, + // Popup + popupClassName, + popupOffset, + popupStyle, - // ActiveKey - activeKey, + // Events + onClick, + onMouseEnter, + onMouseLeave, + onTitleClick, + onTitleMouseEnter, + onTitleMouseLeave, - // SelectKey - selectedKeys, + ...restProps + } = props; - // Icon - itemIcon: contextItemIcon, - expandIcon: contextExpandIcon, + const domDataId = useMenuId(eventKey); - // Events - onItemClick, - onOpenChange, + const { + prefixCls, + mode, + openKeys, - onActive, - } = React.useContext(MenuContext); + // Disabled + disabled: contextDisabled, + overflowDisabled, - const { _internalRenderSubMenuItem } = React.useContext(PrivateContext); + // ActiveKey + activeKey, - const { isSubPathKey } = React.useContext(PathUserContext); - const connectedPath = useFullPath(); + // SelectKey + selectedKeys, - const subMenuPrefixCls = `${prefixCls}-submenu`; - const mergedDisabled = contextDisabled || disabled; - const elementRef = React.useRef(); - const popupRef = React.useRef(); + // Icon + itemIcon: contextItemIcon, + expandIcon: contextExpandIcon, - // ================================ Warn ================================ - if (process.env.NODE_ENV !== 'production' && warnKey) { - warning(false, 'SubMenu should not leave undefined `key`.'); - } - - // ================================ Icon ================================ - const mergedItemIcon = itemIcon ?? contextItemIcon; - const mergedExpandIcon = expandIcon ?? contextExpandIcon; + // Events + onItemClick, + onOpenChange, - // ================================ Open ================================ - const originOpen = openKeys.includes(eventKey); - const open = !overflowDisabled && originOpen; + onActive, + } = React.useContext(MenuContext); - // =============================== Select =============================== - const childrenSelected = isSubPathKey(selectedKeys, eventKey); + const { _internalRenderSubMenuItem } = React.useContext(PrivateContext); - // =============================== Active =============================== - const { active, ...activeProps } = useActive( - eventKey, - mergedDisabled, - onTitleMouseEnter, - onTitleMouseLeave, - ); + const { isSubPathKey } = React.useContext(PathUserContext); + const connectedPath = useFullPath(); - // Fallback of active check to avoid hover on menu title or disabled item - const [childrenActive, setChildrenActive] = React.useState(false); + const subMenuPrefixCls = `${prefixCls}-submenu`; + const mergedDisabled = contextDisabled || disabled; + const elementRef = React.useRef(); + const popupRef = React.useRef(); - const triggerChildrenActive = (newActive: boolean) => { - if (!mergedDisabled) { - setChildrenActive(newActive); + // ================================ Warn ================================ + if (process.env.NODE_ENV !== 'production' && warnKey) { + warning(false, 'SubMenu should not leave undefined `key`.'); } - }; - const onInternalMouseEnter: React.MouseEventHandler< - HTMLLIElement - > = domEvent => { - triggerChildrenActive(true); + // ================================ Icon ================================ + const mergedItemIcon = itemIcon ?? contextItemIcon; + const mergedExpandIcon = expandIcon ?? contextExpandIcon; - onMouseEnter?.({ - key: eventKey, - domEvent, - }); - }; - - const onInternalMouseLeave: React.MouseEventHandler< - HTMLLIElement - > = domEvent => { - triggerChildrenActive(false); + // ================================ Open ================================ + const originOpen = openKeys.includes(eventKey); + const open = !overflowDisabled && originOpen; - onMouseLeave?.({ - key: eventKey, - domEvent, - }); - }; + // =============================== Select =============================== + const childrenSelected = isSubPathKey(selectedKeys, eventKey); - const mergedActive = React.useMemo(() => { - if (active) { - return active; - } - - if (mode !== 'inline') { - return childrenActive || isSubPathKey([activeKey], eventKey); - } - - return false; - }, [mode, active, activeKey, childrenActive, eventKey, isSubPathKey]); - - // ========================== DirectionStyle ========================== - const directionStyle = useDirectionStyle(connectedPath.length); - - // =============================== Events =============================== - // >>>> Title click - const onInternalTitleClick: React.MouseEventHandler = e => { - // Skip if disabled - if (mergedDisabled) { - return; - } + // =============================== Active =============================== + const { active, ...activeProps } = useActive( + eventKey, + mergedDisabled, + onTitleMouseEnter, + onTitleMouseLeave, + ); - onTitleClick?.({ - key: eventKey, - domEvent: e, + // Fallback of active check to avoid hover on menu title or disabled item + const [childrenActive, setChildrenActive] = React.useState(false); + + const triggerChildrenActive = (newActive: boolean) => { + if (!mergedDisabled) { + setChildrenActive(newActive); + } + }; + + const onInternalMouseEnter: React.MouseEventHandler< + HTMLLIElement + > = domEvent => { + triggerChildrenActive(true); + + onMouseEnter?.({ + key: eventKey, + domEvent, + }); + }; + + const onInternalMouseLeave: React.MouseEventHandler< + HTMLLIElement + > = domEvent => { + triggerChildrenActive(false); + + onMouseLeave?.({ + key: eventKey, + domEvent, + }); + }; + + const mergedActive = React.useMemo(() => { + if (active) { + return active; + } + + if (mode !== 'inline') { + return childrenActive || isSubPathKey([activeKey], eventKey); + } + + return false; + }, [mode, active, activeKey, childrenActive, eventKey, isSubPathKey]); + + // ========================== DirectionStyle ========================== + const directionStyle = useDirectionStyle(connectedPath.length); + + // =============================== Events =============================== + // >>>> Title click + const onInternalTitleClick: React.MouseEventHandler = e => { + // Skip if disabled + if (mergedDisabled) { + return; + } + + onTitleClick?.({ + key: eventKey, + domEvent: e, + }); + + // Trigger open by click when mode is `inline` + if (mode === 'inline') { + onOpenChange(eventKey, !originOpen); + } + }; + + // >>>> Context for children click + const onMergedItemClick = useMemoCallback((info: MenuInfo) => { + onClick?.(warnItemProp(info)); + onItemClick(info); }); - // Trigger open by click when mode is `inline` - if (mode === 'inline') { - onOpenChange(eventKey, !originOpen); - } - }; - - // >>>> Context for children click - const onMergedItemClick = useMemoCallback((info: MenuInfo) => { - onClick?.(warnItemProp(info)); - onItemClick(info); - }); - - // >>>>> Visible change - const onPopupVisibleChange = (newVisible: boolean) => { - if (mode !== 'inline') { - onOpenChange(eventKey, newVisible); - } - }; - - /** - * Used for accessibility. Helper will focus element without key board. - * We should manually trigger an active - */ - const onInternalFocus: React.FocusEventHandler = () => { - onActive(eventKey); - }; - - // =============================== Render =============================== - const popupId = domDataId && `${domDataId}-popup`; - - // >>>>> Title - let titleNode: React.ReactElement = ( -
    - {title} - - {/* Only non-horizontal mode shows the icon */} - >>>> Visible change + const onPopupVisibleChange = (newVisible: boolean) => { + if (mode !== 'inline') { + onOpenChange(eventKey, newVisible); + } + }; + + /** + * Used for accessibility. Helper will focus element without key board. + * We should manually trigger an active + */ + const onInternalFocus: React.FocusEventHandler = () => { + onActive(eventKey); + }; + + // =============================== Render =============================== + const popupId = domDataId && `${domDataId}-popup`; + + // >>>>> Title + let titleNode: React.ReactElement = ( +
    + {title} + + {/* Only non-horizontal mode shows the icon */} + { > +
    + ); -
    - ); + // Cache mode if it change to `inline` which do not have popup motion + const triggerModeRef = React.useRef(mode); + if (mode !== 'inline' && connectedPath.length > 1) { + triggerModeRef.current = 'vertical'; + } else { + triggerModeRef.current = mode; + } - // Cache mode if it change to `inline` which do not have popup motion - const triggerModeRef = React.useRef(mode); - if (mode !== 'inline' && connectedPath.length > 1) { - triggerModeRef.current = 'vertical'; - } else { - triggerModeRef.current = mode; - } + if (!overflowDisabled) { + const triggerMode = triggerModeRef.current; + + // Still wrap with Trigger here since we need avoid react re-mount dom node + // Which makes motion failed + titleNode = ( + + + {children} + + + } + disabled={mergedDisabled} + onVisibleChange={onPopupVisibleChange} + > + {titleNode} + + ); + } - if (!overflowDisabled) { - const triggerMode = triggerModeRef.current; - - // Still wrap with Trigger here since we need avoid react re-mount dom node - // Which makes motion failed - titleNode = ( - - - {children} - - - } - disabled={mergedDisabled} - onVisibleChange={onPopupVisibleChange} + // >>>>> List node + let listNode = ( + {titleNode} - - ); - } - // >>>>> List node - let listNode = ( - - {titleNode} - - {/* Inline mode */} - {!overflowDisabled && ( - - {children} - - )} - - ); + {/* Inline mode */} + {!overflowDisabled && ( + + {children} + + )} + + ); - if (_internalRenderSubMenuItem) { - listNode = _internalRenderSubMenuItem(listNode, props, { - selected: childrenSelected, - active: mergedActive, - open, - disabled: mergedDisabled, - }); - } + if (_internalRenderSubMenuItem) { + listNode = _internalRenderSubMenuItem(listNode, props, { + selected: childrenSelected, + active: mergedActive, + open, + disabled: mergedDisabled, + }); + } - // >>>>> Render - return ( - - {listNode} - - ); -}; + // >>>>> Render + return ( + + {listNode} + + ); + }, +); -export default function SubMenu(props: SubMenuProps) { +const SubMenu = React.forwardRef((props, ref) => { const { eventKey, children } = props; const connectedKeyPath = useFullPath(eventKey); @@ -384,7 +386,11 @@ export default function SubMenu(props: SubMenuProps) { if (measure) { renderNode = childList; } else { - renderNode = {childList}; + renderNode = ( + + {childList} + + ); } return ( @@ -392,4 +398,10 @@ export default function SubMenu(props: SubMenuProps) { {renderNode} ); +}); + +if (process.env.NODE_ENV !== 'production') { + SubMenu.displayName = 'SubMenu'; } + +export default SubMenu; diff --git a/src/interface.ts b/src/interface.ts index 5abc3f3c..108632dc 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -2,6 +2,7 @@ import type * as React from 'react'; // ========================= Options ========================= interface ItemSharedProps { + ref?: React.Ref; style?: React.CSSProperties; className?: string; } @@ -66,7 +67,7 @@ export interface MenuItemGroupType extends ItemSharedProps { children?: ItemType[]; } -export interface MenuDividerType extends ItemSharedProps { +export interface MenuDividerType extends Omit { type: 'divider'; } diff --git a/tests/Menu.spec.tsx b/tests/Menu.spec.tsx index 840f6f0b..fc7326ef 100644 --- a/tests/Menu.spec.tsx +++ b/tests/Menu.spec.tsx @@ -225,6 +225,54 @@ describe('Menu', () => { ); }); + it('items support ref', () => { + const ref1 = React.createRef(); + const ref2 = React.createRef(); + const ref3 = React.createRef(); + const ref4 = React.createRef(); + + render( + , + ); + + expect(ref1.current.innerHTML).toBeTruthy(); + expect(ref2.current.innerHTML).toBeTruthy(); + expect(ref3.current.innerHTML).toBeTruthy(); + expect(ref4.current.innerHTML).toBeTruthy(); + }); + it('can be controlled by selectedKeys', () => { const genMenu = (props?) => (