diff --git a/.prettierrc b/.prettierrc index 27dd8afb..73e9b594 100644 --- a/.prettierrc +++ b/.prettierrc @@ -3,5 +3,6 @@ "semi": true, "singleQuote": true, "tabWidth": 2, - "trailingComma": "all" + "trailingComma": "all", + "arrowParens": "avoid" } diff --git a/assets/menu.less b/assets/menu.less new file mode 100644 index 00000000..712cf838 --- /dev/null +++ b/assets/menu.less @@ -0,0 +1,22 @@ +@menuPrefixCls: ~'rc-menu'; + +.@{menuPrefixCls} { + &:focus-visible { + box-shadow: 0 0 5px green; + } + + &-horizontal { + display: flex; + flex-wrap: nowrap; + } + + &-submenu { + &-hidden { + display: none; + } + } + + &-overflow-item { + flex: none; + } +} diff --git a/docs/demo/debug.md b/docs/demo/debug.md new file mode 100644 index 00000000..25e02bfb --- /dev/null +++ b/docs/demo/debug.md @@ -0,0 +1,3 @@ +## debug + + \ No newline at end of file diff --git a/docs/examples/antd-switch.tsx b/docs/examples/antd-switch.tsx index fdbcc534..2d1c5e2b 100644 --- a/docs/examples/antd-switch.tsx +++ b/docs/examples/antd-switch.tsx @@ -28,7 +28,6 @@ const Demo = () => { { console.error('Open Keys Changed:', keys); setOpenKey(keys); diff --git a/docs/examples/antd.tsx b/docs/examples/antd.tsx index b48e8803..d158f55e 100644 --- a/docs/examples/antd.tsx +++ b/docs/examples/antd.tsx @@ -1,7 +1,8 @@ /* eslint-disable no-console, react/require-default-props, no-param-reassign */ import React from 'react'; -import Menu, { SubMenu, Item as MenuItem, Divider } from 'rc-menu'; +import type { CSSMotionProps } from 'rc-motion'; +import Menu, { SubMenu, Item as MenuItem, Divider, MenuProps } from '../../src'; import '../../assets/index.less'; function handleClick(info) { @@ -9,11 +10,30 @@ function handleClick(info) { console.log(info); } -const collapseNode = () => ({ height: 0 }); -const expandNode = node => ({ height: node.scrollHeight }); +const collapseNode = () => { + return { height: 0 }; +}; +const expandNode = node => { + return { height: node.scrollHeight }; +}; + +const horizontalMotion: CSSMotionProps = { + motionName: 'rc-menu-open-slide-up', + motionAppear: true, + motionEnter: true, + motionLeave: true, +}; -export const inlineMotion = { +const verticalMotion: CSSMotionProps = { + motionName: 'rc-menu-open-zoom', + motionAppear: true, + motionEnter: true, + motionLeave: true, +}; + +const inlineMotion: CSSMotionProps = { motionName: 'rc-menu-collapse', + motionAppear: true, onAppearStart: collapseNode, onAppearActive: expandNode, onEnterStart: collapseNode, @@ -22,6 +42,12 @@ export const inlineMotion = { onLeaveActive: collapseNode, }; +const motionMap: Record = { + horizontal: horizontalMotion, + inline: inlineMotion, + vertical: verticalMotion, +}; + const nestSubMenu = ( offset sub menu 2} @@ -95,8 +121,21 @@ const children2 = [ const customizeIndicator = Add More Items; -export class CommonMenu extends React.Component { - state = { +interface CommonMenuProps extends MenuProps { + triggerSubMenuAction?: MenuProps['triggerSubMenuAction']; + updateChildrenAndOverflowedIndicator?: boolean; +} + +interface CommonMenuState { + children: React.ReactNode; + overflowedIndicator: React.ReactNode; +} + +export class CommonMenu extends React.Component< + CommonMenuProps, + CommonMenuState +> { + state: CommonMenuState = { children: children1, overflowedIndicator: undefined, }; @@ -149,7 +188,7 @@ function Demo() { ); @@ -157,13 +196,15 @@ function Demo() { ); - const verticalMenu = ; + const verticalMenu = ( + + ); const inlineMenu = ( diff --git a/docs/examples/custom-icon.tsx b/docs/examples/custom-icon.tsx index 0ca18631..7b1f075b 100644 --- a/docs/examples/custom-icon.tsx +++ b/docs/examples/custom-icon.tsx @@ -1,9 +1,9 @@ /* eslint-disable no-console, no-param-reassign */ import * as React from 'react'; -import Menu, { SubMenu, Item as MenuItem, Divider } from 'rc-menu'; +import Menu, { SubMenu, Item as MenuItem, Divider } from '../../src'; import '../../assets/index.less'; -const getSvgIcon = (style = {}, text) => { +const getSvgIcon = (style = {}, text?: React.ReactNode) => { if (text) { return {text}; } diff --git a/docs/examples/debug.tsx b/docs/examples/debug.tsx new file mode 100644 index 00000000..052b27d7 --- /dev/null +++ b/docs/examples/debug.tsx @@ -0,0 +1,158 @@ +/* eslint no-console:0 */ + +import React from 'react'; +import type { CSSMotionProps } from 'rc-motion'; +import Menu, { ItemGroup as MenuItemGroup } from '../../src'; +import type { MenuProps } from '../../src'; +import '../../assets/index.less'; +import '../../assets/menu.less'; +import type { MenuInfo } from '@/interface'; + +const collapseNode = () => { + return { height: 0 }; +}; +const expandNode = node => { + return { height: node.scrollHeight }; +}; + +const horizontalMotion: CSSMotionProps = { + motionName: 'rc-menu-open-slide-up', + motionAppear: true, + motionEnter: true, + motionLeave: true, +}; + +const verticalMotion: CSSMotionProps = { + motionName: 'rc-menu-open-zoom', + motionAppear: true, + motionEnter: true, + motionLeave: true, +}; + +const inlineMotion: CSSMotionProps = { + motionName: 'rc-menu-collapse', + motionAppear: true, + onAppearStart: collapseNode, + onAppearActive: expandNode, + onEnterStart: collapseNode, + onEnterActive: expandNode, + onLeaveStart: expandNode, + onLeaveActive: collapseNode, +}; + +const motionMap: Record = { + horizontal: horizontalMotion, + inline: inlineMotion, + vertical: verticalMotion, +}; + +export default () => { + const [mode, setMode] = React.useState('horizontal'); + const [narrow, setNarrow] = React.useState(false); + const [inlineCollapsed, setInlineCollapsed] = React.useState(false); + const [forceRender, setForceRender] = React.useState(false); + const [openKeys, setOpenKeys] = React.useState([]); + + const onRootClick = (info: MenuInfo) => { + console.log('Root Menu Item Click:', info); + }; + + const onSubMenuClick = (info: MenuInfo) => { + console.log('Sub Menu Item Click:', info); + }; + + const onClick = (info: MenuInfo) => { + console.log('Menu Item Click:', info); + }; + + return ( + <> +
+ + + {/* Narrow */} + + + {/* InlineCollapsed */} + + + {/* forceRender */} + +
+ +
+ setOpenKeys(newOpenKeys)} + > + + Navigation One + + + Next Item + + + + Sub Item 1 + + Sub Item 2 + + + + 2 + 3 + + + 4 + 5 + + + + + Disabled Item + + + + + Disabled Sub Item 1 + + + +
+ + ); +}; diff --git a/docs/examples/rtl-antd.tsx b/docs/examples/rtl-antd.tsx index 6b345570..a75acaea 100644 --- a/docs/examples/rtl-antd.tsx +++ b/docs/examples/rtl-antd.tsx @@ -1,6 +1,7 @@ /* eslint-disable no-console, react/require-default-props, no-param-reassign */ import React from 'react'; +import type { CSSMotionProps } from 'rc-motion'; import Menu, { SubMenu, Item as MenuItem, Divider } from 'rc-menu'; import '../../assets/index.less'; @@ -9,11 +10,30 @@ function handleClick(info) { console.log(info); } -const collapseNode = () => ({ height: 0 }); -const expandNode = node => ({ height: node.scrollHeight }); +const collapseNode = () => { + return { height: 0 }; +}; +const expandNode = node => { + return { height: node.scrollHeight }; +}; -const inlineMotion = { +const horizontalMotion: CSSMotionProps = { + motionName: 'rc-menu-open-slide-up', + motionAppear: true, + motionEnter: true, + motionLeave: true, +}; + +const verticalMotion: CSSMotionProps = { + motionName: 'rc-menu-open-zoom', + motionAppear: true, + motionEnter: true, + motionLeave: true, +}; + +const inlineMotion: CSSMotionProps = { motionName: 'rc-menu-collapse', + motionAppear: true, onAppearStart: collapseNode, onAppearActive: expandNode, onEnterStart: collapseNode, @@ -22,6 +42,12 @@ const inlineMotion = { onLeaveActive: collapseNode, }; +const motionMap: Record = { + horizontal: horizontalMotion, + inline: inlineMotion, + vertical: verticalMotion, +}; + const nestSubMenu = ( offset sub menu 2} @@ -115,11 +141,11 @@ class CommonMenu extends React.Component { }; render() { - const { triggerSubMenuAction } = this.props; + const { updateChildrenAndOverflowedIndicator, ...restProps } = this.props; const { children, overflowedIndicator } = this.state; return (
- {this.props.updateChildrenAndOverflowedIndicator && ( + {updateChildrenAndOverflowedIndicator && (
+
    {children}
+ + ); +}; + +export default function MenuItemGroup({ + children, + ...props +}: MenuItemGroupProps): React.ReactElement { + const connectedKeyPath = useKeyPath(props.eventKey); + const childList: React.ReactElement[] = parseChildren( + children, + connectedKeyPath, + ); + + const measure = useMeasure(); + if (measure) { + return (childList as any) as React.ReactElement; } -} -export default MenuItemGroup; + return {childList}; +} diff --git a/src/SubMenu.tsx b/src/SubMenu.tsx deleted file mode 100644 index b44ac2da..00000000 --- a/src/SubMenu.tsx +++ /dev/null @@ -1,710 +0,0 @@ -import * as React from 'react'; -import * as ReactDOM from 'react-dom'; -import Trigger from 'rc-trigger'; -import raf from 'rc-util/lib/raf'; -import KeyCode from 'rc-util/lib/KeyCode'; -import type { CSSMotionProps } from 'rc-motion'; -import CSSMotion from 'rc-motion'; -import classNames from 'classnames'; -import { connect } from 'mini-store'; -import type { SubPopupMenuProps } from './SubPopupMenu'; -import SubPopupMenu from './SubPopupMenu'; -import { placements, placementsRtl } from './placements'; -import { - noop, - loopMenuItemRecursively, - getMenuIdFromSubMenuEventKey, - menuAllProps, -} from './util'; -import type { - MiniStore, - RenderIconType, - LegacyFunctionRef, - MenuMode, - OpenEventHandler, - SelectEventHandler, - DestroyEventHandler, - MenuHoverEventHandler, - MenuClickEventHandler, - MenuInfo, - BuiltinPlacements, - TriggerSubMenuAction, - HoverEventHandler, -} from './interface'; -import type { MenuItem } from './MenuItem'; - -let guid = 0; - -const popupPlacementMap = { - horizontal: 'bottomLeft', - vertical: 'rightTop', - 'vertical-left': 'rightTop', - 'vertical-right': 'leftTop', -}; - -const updateDefaultActiveFirst = ( - store: MiniStore, - eventKey: string, - defaultActiveFirst: boolean, -) => { - const menuId = getMenuIdFromSubMenuEventKey(eventKey); - const state = store.getState(); - store.setState({ - defaultActiveFirst: { - ...state.defaultActiveFirst, - [menuId]: defaultActiveFirst, - }, - }); -}; - -export interface SubMenuProps { - parentMenu?: React.ReactElement & { - isRootMenu: boolean; - subMenuInstance: React.ReactInstance; - }; - title?: React.ReactNode; - children?: React.ReactNode; - selectedKeys?: string[]; - openKeys?: string[]; - onClick?: MenuClickEventHandler; - onOpenChange?: OpenEventHandler; - rootPrefixCls?: string; - eventKey?: string; - multiple?: boolean; - active?: boolean; // TODO: remove - onItemHover?: HoverEventHandler; - onSelect?: SelectEventHandler; - triggerSubMenuAction?: TriggerSubMenuAction; - onDeselect?: SelectEventHandler; - onDestroy?: DestroyEventHandler; - onMouseEnter?: MenuHoverEventHandler; - onMouseLeave?: MenuHoverEventHandler; - onTitleMouseEnter?: MenuHoverEventHandler; - onTitleMouseLeave?: MenuHoverEventHandler; - onTitleClick?: (info: { - key: React.Key; - domEvent: React.MouseEvent | React.KeyboardEvent; - }) => void; - popupOffset?: number[]; - isOpen?: boolean; - store?: MiniStore; - mode?: MenuMode; - manualRef?: LegacyFunctionRef; - itemIcon?: RenderIconType; - expandIcon?: RenderIconType; - inlineIndent?: number; - level?: number; - subMenuOpenDelay?: number; - subMenuCloseDelay?: number; - forceSubMenuRender?: boolean; - builtinPlacements?: BuiltinPlacements; - disabled?: boolean; - className?: string; - popupClassName?: string; - - motion?: CSSMotionProps; - direction?: 'ltr' | 'rtl'; -} - -interface SubMenuState { - mode: MenuMode; - isOpen: boolean; -} - -export class SubMenu extends React.Component { - static defaultProps = { - onMouseEnter: noop, - onMouseLeave: noop, - onTitleMouseEnter: noop, - onTitleMouseLeave: noop, - onTitleClick: noop, - manualRef: noop, - mode: 'vertical', - title: '', - }; - - constructor(props: SubMenuProps) { - super(props); - const { store, eventKey } = props; - const { defaultActiveFirst } = store.getState(); - - this.isRootMenu = false; - - let value = false; - - if (defaultActiveFirst) { - value = defaultActiveFirst[eventKey]; - } - - updateDefaultActiveFirst(store, eventKey, value); - - this.state = { - mode: props.mode, - isOpen: props.isOpen, - }; - } - - isRootMenu: boolean; - - menuInstance: MenuItem; - - subMenuTitle: HTMLElement; - - internalMenuId: string; - - haveRendered: boolean; - - haveOpened: boolean; - - updateStateRaf: number; - - /** - * Follow timeout should be `number`. - * Current is only convert code into TS, - * we not use `window.setTimeout` instead of `setTimeout`. - */ - minWidthTimeout: any; - - mouseenterTimeout: any; - - componentDidMount() { - this.componentDidUpdate(); - } - - componentDidUpdate() { - const { mode, parentMenu, manualRef, isOpen } = this.props; - - const updateState = () => { - this.setState({ - mode, - isOpen, - }); - }; - - // Delay sync when mode changed in case openKeys change not sync - const isOpenChanged = isOpen !== this.state.isOpen; - const isModeChanged = mode !== this.state.mode; - if (isModeChanged || isOpenChanged) { - raf.cancel(this.updateStateRaf); - - if (isModeChanged) { - this.updateStateRaf = raf(updateState); - } else { - updateState(); - } - } - - // invoke customized ref to expose component to mixin - if (manualRef) { - manualRef(this); - } - - if (mode !== 'horizontal' || !parentMenu?.isRootMenu || !isOpen) { - return; - } - - this.minWidthTimeout = setTimeout(() => this.adjustWidth(), 0); - } - - componentWillUnmount() { - const { onDestroy, eventKey } = this.props; - if (onDestroy) { - onDestroy(eventKey); - } - - /* istanbul ignore if */ - if (this.minWidthTimeout) { - clearTimeout(this.minWidthTimeout); - } - - /* istanbul ignore if */ - if (this.mouseenterTimeout) { - clearTimeout(this.mouseenterTimeout); - } - - raf.cancel(this.updateStateRaf); - } - - onDestroy = (key: string) => { - this.props.onDestroy(key); - }; - - /** - * note: - * This legacy code that `onKeyDown` is called by parent instead of dom self. - * which need return code to check if this event is handled - */ - onKeyDown: React.KeyboardEventHandler = (e) => { - const { keyCode } = e; - const menu = this.menuInstance; - const { store } = this.props; - const visible = this.getVisible(); - - if (keyCode === KeyCode.ENTER) { - this.onTitleClick(e); - updateDefaultActiveFirst(store, this.props.eventKey, true); - return true; - } - - if (keyCode === KeyCode.RIGHT) { - if (visible) { - menu.onKeyDown(e); - } else { - this.triggerOpenChange(true); - // need to update current menu's defaultActiveFirst value - updateDefaultActiveFirst(store, this.props.eventKey, true); - } - return true; - } - if (keyCode === KeyCode.LEFT) { - let handled: boolean; - if (visible) { - handled = menu.onKeyDown(e); - } else { - return undefined; - } - if (!handled) { - this.triggerOpenChange(false); - handled = true; - } - return handled; - } - - if (visible && (keyCode === KeyCode.UP || keyCode === KeyCode.DOWN)) { - return menu.onKeyDown(e); - } - - return undefined; - }; - - onOpenChange: OpenEventHandler = (e) => { - this.props.onOpenChange(e); - }; - - onPopupVisibleChange = (visible: boolean) => { - this.triggerOpenChange(visible, visible ? 'mouseenter' : 'mouseleave'); - }; - - onMouseEnter: React.MouseEventHandler = (e) => { - const { eventKey: key, onMouseEnter, store } = this.props; - updateDefaultActiveFirst(store, this.props.eventKey, false); - onMouseEnter({ - key, - domEvent: e, - }); - }; - - onMouseLeave: React.MouseEventHandler = (e) => { - const { parentMenu, eventKey, onMouseLeave } = this.props; - parentMenu.subMenuInstance = this; - onMouseLeave({ - key: eventKey, - domEvent: e, - }); - }; - - onTitleMouseEnter: React.MouseEventHandler = (domEvent) => { - const { eventKey: key, onItemHover, onTitleMouseEnter } = this.props; - onItemHover({ - key, - hover: true, - }); - onTitleMouseEnter({ - key, - domEvent, - }); - }; - - onTitleMouseLeave: React.MouseEventHandler = (e) => { - const { parentMenu, eventKey, onItemHover, onTitleMouseLeave } = this.props; - parentMenu.subMenuInstance = this; - onItemHover({ - key: eventKey, - hover: false, - }); - onTitleMouseLeave({ - key: eventKey, - domEvent: e, - }); - }; - - onTitleClick = ( - e: React.MouseEvent | React.KeyboardEvent, - ) => { - const { props } = this; - props.onTitleClick({ - key: props.eventKey, - domEvent: e, - }); - if (props.triggerSubMenuAction === 'hover') { - return; - } - this.triggerOpenChange(!this.getVisible(), 'click'); - updateDefaultActiveFirst(props.store, this.props.eventKey, false); - }; - - onSubMenuClick = (info: MenuInfo) => { - // in the case of overflowed submenu - // onClick is not copied over - if (typeof this.props.onClick === 'function') { - this.props.onClick(this.addKeyPath(info)); - } - }; - - onSelect: SelectEventHandler = (info) => { - this.props.onSelect(info); - }; - - onDeselect: SelectEventHandler = (info) => { - this.props.onDeselect(info); - }; - - getPrefixCls = () => `${this.props.rootPrefixCls}-submenu`; - - getActiveClassName = () => `${this.getPrefixCls()}-active`; - - getDisabledClassName = () => `${this.getPrefixCls()}-disabled`; - - getSelectedClassName = () => `${this.getPrefixCls()}-selected`; - - getOpenClassName = () => `${this.props.rootPrefixCls}-submenu-open`; - - getVisible = () => this.state.isOpen; - - getMode = () => this.state.mode; - - saveMenuInstance = (c: MenuItem) => { - // children menu instance - this.menuInstance = c; - }; - - addKeyPath = (info: MenuInfo) => ({ - ...info, - keyPath: (info.keyPath || []).concat(this.props.eventKey), - }); - - triggerOpenChange = (open: boolean, type?: string) => { - const key = this.props.eventKey; - const openChange = () => { - this.onOpenChange({ - key, - item: this, - trigger: type, - open, - }); - }; - if (type === 'mouseenter') { - // make sure mouseenter happen after other menu item's mouseleave - this.mouseenterTimeout = setTimeout(() => { - openChange(); - }, 0); - } else { - openChange(); - } - }; - - isChildrenSelected = () => { - const ret = { find: false }; - loopMenuItemRecursively(this.props.children, this.props.selectedKeys, ret); - return ret.find; - }; - - isInlineMode = () => this.getMode() === 'inline'; - - adjustWidth = () => { - /* istanbul ignore if */ - if (!this.subMenuTitle || !this.menuInstance) { - return; - } - const popupMenu = ReactDOM.findDOMNode(this.menuInstance) as HTMLElement; - if (popupMenu.offsetWidth >= this.subMenuTitle.offsetWidth) { - return; - } - - /* istanbul ignore next */ - popupMenu.style.minWidth = `${this.subMenuTitle.offsetWidth}px`; - }; - - saveSubMenuTitle = (subMenuTitle: HTMLElement) => { - this.subMenuTitle = subMenuTitle; - }; - - getBaseProps = (): SubPopupMenuProps => { - const { props } = this; - const mergedMode = this.getMode(); - - return { - mode: mergedMode === 'horizontal' ? 'vertical' : mergedMode, - visible: this.getVisible(), - level: props.level + 1, - inlineIndent: props.inlineIndent, - focusable: false, - onClick: this.onSubMenuClick, - onSelect: this.onSelect, - onDeselect: this.onDeselect, - onDestroy: this.onDestroy as any, - selectedKeys: props.selectedKeys, - eventKey: `${props.eventKey}-menu-`, - openKeys: props.openKeys, - motion: props.motion, - onOpenChange: this.onOpenChange, - subMenuOpenDelay: props.subMenuOpenDelay, - parentMenu: this, - subMenuCloseDelay: props.subMenuCloseDelay, - forceSubMenuRender: props.forceSubMenuRender, - triggerSubMenuAction: props.triggerSubMenuAction, - builtinPlacements: props.builtinPlacements, - defaultActiveFirst: props.store.getState().defaultActiveFirst[ - getMenuIdFromSubMenuEventKey(props.eventKey) - ], - multiple: props.multiple, - prefixCls: props.rootPrefixCls, - id: this.internalMenuId, - manualRef: this.saveMenuInstance as any, - itemIcon: props.itemIcon, - expandIcon: props.expandIcon, - direction: props.direction, - }; - }; - - getMotion = (mode: MenuMode, visible: boolean) => { - const { haveRendered } = this; - const { motion, rootPrefixCls } = this.props; - - // don't show transition on first rendering (no animation for opened menu) - // show appear transition if it's not visible (not sure why) - // show appear transition if it's not inline mode - const mergedMotion: CSSMotionProps = { - ...motion, - leavedClassName: `${rootPrefixCls}-hidden`, - removeOnLeave: false, - motionAppear: haveRendered || !visible || mode !== 'inline', - }; - - return mergedMotion; - }; - - renderPopupMenu(className: string, style?: React.CSSProperties) { - const baseProps = this.getBaseProps(); - - /** - * zombiej: Why SubPopupMenu here? - * Seems whatever popup or inline mode both will render SubPopupMenu. - * It's controlled by Trigger for popup or not. - */ - return ( - - {this.props.children} - - ); - } - - renderChildren() { - const baseProps = this.getBaseProps(); - const { mode, visible, forceSubMenuRender, direction } = baseProps; - - // [Legacy] getMotion must be called before `haveRendered` - const mergedMotion = this.getMotion(mode, visible); - - this.haveRendered = true; - - this.haveOpened = this.haveOpened || visible || forceSubMenuRender; - // never rendered not planning to, don't render - if (!this.haveOpened) { - return
; - } - - const sharedClassName = classNames(`${baseProps.prefixCls}-sub`, { - [`${baseProps.prefixCls}-rtl`]: direction === 'rtl', - }); - - if (!this.isInlineMode()) { - return this.renderPopupMenu(sharedClassName); - } - - return ( - - {({ className, style }) => { - const mergedClassName = classNames(sharedClassName, className); - - return this.renderPopupMenu(mergedClassName, style); - }} - - ); - } - - render() { - const props = { ...this.props }; - const visible = this.getVisible(); - const prefixCls = this.getPrefixCls(); - const inline = this.isInlineMode(); - const mergedMode = this.getMode(); - const className = classNames(prefixCls, `${prefixCls}-${mergedMode}`, { - [props.className]: !!props.className, - [this.getOpenClassName()]: visible, - [this.getActiveClassName()]: props.active || (visible && !inline), - [this.getDisabledClassName()]: props.disabled, - [this.getSelectedClassName()]: this.isChildrenSelected(), - }); - - if (!this.internalMenuId) { - if (props.eventKey) { - this.internalMenuId = `${props.eventKey}$Menu`; - } else { - guid += 1; - this.internalMenuId = `$__$${guid}$Menu`; - } - } - - let mouseEvents = {}; - let titleClickEvents = {}; - let titleMouseEvents = {}; - if (!props.disabled) { - mouseEvents = { - onMouseLeave: this.onMouseLeave, - onMouseEnter: this.onMouseEnter, - }; - - // only works in title, not outer li - titleClickEvents = { - onClick: this.onTitleClick, - }; - titleMouseEvents = { - onMouseEnter: this.onTitleMouseEnter, - onMouseLeave: this.onTitleMouseLeave, - }; - } - - const style: React.CSSProperties = {}; - - const { direction } = props; - const isRTL = direction === 'rtl'; - - if (inline) { - if (isRTL) { - style.paddingRight = props.inlineIndent * props.level; - } else { - style.paddingLeft = props.inlineIndent * props.level; - } - } - - let ariaOwns = {}; - // only set aria-owns when menu is open - // otherwise it would be an invalid aria-owns value - // since corresponding node cannot be found - if (this.getVisible()) { - ariaOwns = { - 'aria-owns': this.internalMenuId, - }; - } - - // expand custom icon should NOT be displayed in menu with horizontal mode. - let icon = null; - if (mergedMode !== 'horizontal') { - icon = this.props.expandIcon; // ReactNode - if (typeof this.props.expandIcon === 'function') { - icon = React.createElement(this.props.expandIcon as any, { - ...this.props, - }); - } - } - - const title = ( -
- {props.title} - {icon || } -
- ); - - const children = this.renderChildren(); - - const getPopupContainer = props.parentMenu?.isRootMenu - ? props.parentMenu.props.getPopupContainer - : (triggerNode: HTMLElement) => triggerNode.parentNode; - const popupPlacement = popupPlacementMap[mergedMode]; - const popupAlign = props.popupOffset ? { offset: props.popupOffset } : {}; - const popupClassName = classNames({ - [props.popupClassName]: props.popupClassName && !inline, - [`${prefixCls}-rtl`]: isRTL, - }); - const { - disabled, - triggerSubMenuAction, - subMenuOpenDelay, - forceSubMenuRender, - subMenuCloseDelay, - builtinPlacements, - } = props; - menuAllProps.forEach((key) => delete props[key]); - // Set onClick to null, to ignore propagated onClick event - delete props.onClick; - const placement = isRTL - ? { ...placementsRtl, ...builtinPlacements } - : { ...placements, ...builtinPlacements }; - delete props.direction; - - // [Legacy] It's a fast fix, - // but we should check if we can refactor this to make code more easy to understand - const baseProps = this.getBaseProps(); - const mergedMotion: CSSMotionProps = inline - ? null - : this.getMotion(baseProps.mode, baseProps.visible); - - return ( -
  • - - {title} - - {inline ? children : null} -
  • - ); - } -} - -const connected = connect( - ({ openKeys, activeKey, selectedKeys }, { eventKey, subMenuKey }) => ({ - isOpen: openKeys.indexOf(eventKey) > -1, - active: activeKey[subMenuKey] === eventKey, - selectedKeys, - }), -)(SubMenu as any); - -connected.isSubMenu = true; - -export default connected; diff --git a/src/SubMenu/InlineSubMenuList.tsx b/src/SubMenu/InlineSubMenuList.tsx new file mode 100644 index 00000000..b5f48b8d --- /dev/null +++ b/src/SubMenu/InlineSubMenuList.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import CSSMotion from 'rc-motion'; +import { getMotion } from '../utils/motionUtil'; +import MenuContextProvider, { MenuContext } from '../context/MenuContext'; +import SubMenuList from './SubMenuList'; +import type { MenuMode } from '../interface'; + +export interface InlineSubMenuListProps { + id?: string; + open: boolean; + keyPath: string[]; + children: React.ReactNode; +} + +export default function InlineSubMenuList({ + id, + open, + keyPath, + children, +}: InlineSubMenuListProps) { + const fixedMode: MenuMode = 'inline'; + + const { + prefixCls, + forceSubMenuRender, + motion, + defaultMotions, + mode, + } = React.useContext(MenuContext); + + // Always use latest mode check + const sameModeRef = React.useRef(false); + sameModeRef.current = mode === fixedMode; + + // We record `destroy` mark here since when mode change from `inline` to others. + // The inline list should remove when motion end. + const [destroy, setDestroy] = React.useState(!sameModeRef.current); + + const mergedOpen = sameModeRef.current ? open : false; + + // ================================= Effect ================================= + // Reset destroy state when mode change back + React.useEffect(() => { + if (sameModeRef.current) { + setDestroy(false); + } + }, [mode]); + + // ================================= Render ================================= + const mergedMotion = { ...getMotion(fixedMode, motion, defaultMotions) }; + + // No need appear since nest inlineCollapse changed + if (keyPath.length > 1) { + mergedMotion.motionAppear = false; + } + + // Hide inline list when mode changed and motion end + const originOnVisibleChanged = mergedMotion.onVisibleChanged; + mergedMotion.onVisibleChanged = newVisible => { + if (!sameModeRef.current && !newVisible) { + setDestroy(true); + } + + return originOnVisibleChanged?.(newVisible); + }; + + if (destroy) { + return null; + } + + return ( + + + {({ className: motionClassName, style: motionStyle }) => { + return ( + + {children} + + ); + }} + + + ); +} diff --git a/src/SubMenu/PopupTrigger.tsx b/src/SubMenu/PopupTrigger.tsx new file mode 100644 index 00000000..b0f58061 --- /dev/null +++ b/src/SubMenu/PopupTrigger.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; +import Trigger from 'rc-trigger'; +import classNames from 'classnames'; +import raf from 'rc-util/lib/raf'; +import type { CSSMotionProps } from 'rc-motion'; +import { MenuContext } from '../context/MenuContext'; +import { placements, placementsRtl } from '../placements'; +import type { MenuMode } from '../interface'; +import { getMotion } from '../utils/motionUtil'; + +const popupPlacementMap = { + horizontal: 'bottomLeft', + vertical: 'rightTop', + 'vertical-left': 'rightTop', + 'vertical-right': 'leftTop', +}; + +export interface PopupTriggerProps { + prefixCls: string; + mode: MenuMode; + visible: boolean; + children: React.ReactElement; + popup: React.ReactNode; + popupClassName?: string; + popupOffset?: number[]; + disabled: boolean; + onVisibleChange: (visible: boolean) => void; +} + +export default function PopupTrigger({ + prefixCls, + visible, + children, + popup, + popupClassName, + popupOffset, + disabled, + mode, + onVisibleChange, +}: PopupTriggerProps) { + const { + getPopupContainer, + rtl, + subMenuOpenDelay, + subMenuCloseDelay, + builtinPlacements, + triggerSubMenuAction, + forceSubMenuRender, + + // Motion + motion, + defaultMotions, + } = React.useContext(MenuContext); + + const [innerVisible, setInnerVisible] = React.useState(false); + + const placement = rtl + ? { ...placementsRtl, ...builtinPlacements } + : { ...placements, ...builtinPlacements }; + + const popupPlacement = popupPlacementMap[mode]; + + const targetMotion = getMotion(mode, motion, defaultMotions); + + const mergedMotion: CSSMotionProps = { + ...targetMotion, + leavedClassName: `${prefixCls}-hidden`, + removeOnLeave: false, + motionAppear: true, + }; + + // Delay to change visible + const visibleRef = React.useRef(); + React.useEffect(() => { + visibleRef.current = raf(() => { + setInnerVisible(visible); + }); + + return () => { + raf.cancel(visibleRef.current); + }; + }, [visible]); + + return ( + + {children} + + ); +} diff --git a/src/SubMenu/SubMenuList.tsx b/src/SubMenu/SubMenuList.tsx new file mode 100644 index 00000000..cda68a17 --- /dev/null +++ b/src/SubMenu/SubMenuList.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import { MenuContext } from '../context/MenuContext'; + +export interface SubMenuListProps + extends React.HTMLAttributes { + children?: React.ReactNode; +} + +const InternalSubMenuList = ( + { className, children, ...restProps }: SubMenuListProps, + ref: React.Ref, +) => { + const { prefixCls, mode } = React.useContext(MenuContext); + + return ( +
      + {children} +
    + ); +}; + +const SubMenuList = React.forwardRef(InternalSubMenuList); +SubMenuList.displayName = 'SubMenuList'; + +export default SubMenuList; diff --git a/src/SubMenu/index.tsx b/src/SubMenu/index.tsx new file mode 100644 index 00000000..bde7022b --- /dev/null +++ b/src/SubMenu/index.tsx @@ -0,0 +1,365 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import Overflow from 'rc-overflow'; +import SubMenuList from './SubMenuList'; +import { parseChildren } from '../utils/nodeUtil'; +import type { + MenuClickEventHandler, + MenuHoverEventHandler, + MenuInfo, + MenuTitleInfo, + RenderIconType, +} from '../interface'; +import MenuContextProvider, { MenuContext } from '../context/MenuContext'; +import useMemoCallback from '../hooks/useMemoCallback'; +import PopupTrigger from './PopupTrigger'; +import Icon from '../Icon'; +import useActive from '../hooks/useActive'; +import { warnItemProp } from '../utils/warnUtil'; +import useDirectionStyle from '../hooks/useDirectionStyle'; +import InlineSubMenuList from './InlineSubMenuList'; +import { + PathConnectContext, + PathUserContext, + useKeyPath, + useMeasure, +} from '../context/MeasureContext'; +import { useMenuId } from '../context/IdContext'; + +export interface SubMenuProps { + style?: React.CSSProperties; + className?: string; + + title?: React.ReactNode; + children?: React.ReactNode; + + disabled?: boolean; + /** @private Used for rest popup. Do not use in your prod */ + internalPopupClose?: boolean; + + /** @private Internal filled key. Do not set it directly */ + eventKey?: string; + + // >>>>> Icon + itemIcon?: RenderIconType; + expandIcon?: RenderIconType; + + // >>>>> Active + onMouseEnter?: MenuHoverEventHandler; + onMouseLeave?: MenuHoverEventHandler; + + // >>>>> Popup + popupClassName?: string; + popupOffset?: number[]; + + // >>>>> Events + onClick?: MenuClickEventHandler; + onTitleClick?: (info: MenuTitleInfo) => void; + onTitleMouseEnter?: MenuHoverEventHandler; + onTitleMouseLeave?: MenuHoverEventHandler; + + // >>>>>>>>>>>>>>>>>>>>> Next Round <<<<<<<<<<<<<<<<<<<<<<< + // onDestroy?: DestroyEventHandler; +} + +const InternalSubMenu = (props: SubMenuProps) => { + const { + style, + className, + + title, + eventKey, + + disabled, + internalPopupClose, + + children, + + // Icons + itemIcon, + expandIcon, + + // Popup + popupClassName, + popupOffset, + + // Events + onClick, + onMouseEnter, + onMouseLeave, + onTitleClick, + onTitleMouseEnter, + onTitleMouseLeave, + + ...restProps + } = props; + + const domDataId = useMenuId(eventKey); + + const { + prefixCls, + mode, + openKeys, + + // Disabled + disabled: contextDisabled, + overflowDisabled, + + // ActiveKey + activeKey, + + // SelectKey + selectedKeys, + + // Icon + itemIcon: contextItemIcon, + expandIcon: contextExpandIcon, + + // Events + onItemClick, + onOpenChange, + + onActive, + } = React.useContext(MenuContext); + + const { isSubPathKey, getKeyPath } = React.useContext(PathUserContext); + const keyPath = getKeyPath(eventKey); + + const subMenuPrefixCls = `${prefixCls}-submenu`; + const mergedDisabled = contextDisabled || disabled; + const elementRef = React.useRef(); + const popupRef = React.useRef(); + + // ================================ Icon ================================ + const mergedItemIcon = itemIcon || contextItemIcon; + const mergedExpandIcon = expandIcon || contextExpandIcon; + + // ================================ Open ================================ + const originOpen = openKeys.includes(eventKey); + const open = !overflowDisabled && originOpen; + + // =============================== Select =============================== + const childrenSelected = isSubPathKey(selectedKeys, eventKey); + + // =============================== Active =============================== + const { active, ...activeProps } = useActive( + eventKey, + mergedDisabled, + onTitleMouseEnter, + onTitleMouseLeave, + ); + + // 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 = domEvent => { + triggerChildrenActive(true); + + onMouseEnter?.({ + key: eventKey, + domEvent, + }); + }; + + const onInternalMouseLeave: React.MouseEventHandler = 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(keyPath.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); + }); + + // >>>>> Visible change + const onPopupVisibleChange = (newVisible: boolean) => { + 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}-popup`; + + // >>>>> Title + let titleNode: React.ReactElement = ( +
    + {title} + + {/* Only non-horizontal mode shows the icon */} + + + +
    + ); + + if (mode !== 'inline' && !overflowDisabled) { + titleNode = ( + + {children} + + } + disabled={mergedDisabled} + onVisibleChange={onPopupVisibleChange} + > + {titleNode} + + ); + } + + // >>>>> Render + return ( + + + {titleNode} + + {/* Inline mode */} + {!overflowDisabled && ( + + {children} + + )} + + + ); +}; + +export default function SubMenu(props: SubMenuProps) { + const { eventKey, children } = props; + + const connectedKeyPath = useKeyPath(eventKey); + const childList: React.ReactElement[] = parseChildren( + children, + connectedKeyPath, + ); + + // ==================== Record KeyPath ==================== + const measure = useMeasure(); + + // eslint-disable-next-line consistent-return + React.useEffect(() => { + if (measure) { + measure.registerPath(eventKey, connectedKeyPath); + + return () => { + measure.unregisterPath(eventKey, connectedKeyPath); + }; + } + }, [connectedKeyPath]); + + if (measure) { + return ( + + {childList} + + ); + } + + // ======================== Render ======================== + + return {childList}; +} diff --git a/src/SubPopupMenu.tsx b/src/SubPopupMenu.tsx deleted file mode 100644 index 10467584..00000000 --- a/src/SubPopupMenu.tsx +++ /dev/null @@ -1,483 +0,0 @@ -import * as React from 'react'; -import { connect } from 'mini-store'; -import type { CSSMotionProps } from 'rc-motion'; -import KeyCode from 'rc-util/lib/KeyCode'; -import createChainedFunction from 'rc-util/lib/createChainedFunction'; -import toArray from 'rc-util/lib/Children/toArray'; -import shallowEqual from 'shallowequal'; -import classNames from 'classnames'; -import { - getKeyFromChildrenIndex, - loopMenuItem, - noop, - menuAllProps, - isMobileDevice, -} from './util'; -import DOMWrap from './DOMWrap'; -import type { - SelectEventHandler, - OpenEventHandler, - DestroyEventHandler, - MiniStore, - MenuMode, - LegacyFunctionRef, - RenderIconType, - HoverEventHandler, - BuiltinPlacements, - MenuClickEventHandler, - MenuInfo, - TriggerSubMenuAction, -} from './interface'; -import type { MenuItem, MenuItemProps } from './MenuItem'; -import type { MenuItemGroupProps } from './MenuItemGroup'; - -function allDisabled(arr: MenuItem[]) { - if (!arr.length) { - return true; - } - return arr.every((c) => !!c.props.disabled); -} - -function updateActiveKey( - store: MiniStore, - menuId: React.Key, - activeKey: React.Key, -) { - const state = store.getState(); - store.setState({ - activeKey: { - ...state.activeKey, - [menuId]: activeKey, - }, - }); -} - -function getEventKey(props: SubPopupMenuProps): React.Key { - // when eventKey not available ,it's menu and return menu id '0-menu-' - return props.eventKey || '0-menu-'; -} - -export function getActiveKey( - props: { - children?: React.ReactNode; - eventKey?: React.Key; - defaultActiveFirst?: boolean; - }, - originalActiveKey: string, -) { - let activeKey: React.Key = originalActiveKey; - const { children, eventKey } = props; - if (activeKey) { - let found: boolean; - loopMenuItem(children, (c, i) => { - if ( - c && - c.props && - !c.props.disabled && - activeKey === getKeyFromChildrenIndex(c, eventKey, i) - ) { - found = true; - } - }); - if (found) { - return activeKey; - } - } - activeKey = null; - if (props.defaultActiveFirst) { - loopMenuItem(children, (c, i) => { - if (!activeKey && c && !c.props.disabled) { - activeKey = getKeyFromChildrenIndex(c, eventKey, i); - } - }); - return activeKey; - } - return activeKey; -} - -export function saveRef(c: React.ReactInstance) { - if (!c) { - return; - } - /* eslint-disable @typescript-eslint/no-invalid-this */ - const index = this.instanceArray.indexOf(c); - if (index !== -1) { - // update component if it's already inside instanceArray - this.instanceArray[index] = c; - } else { - // add component if it's not in instanceArray yet; - this.instanceArray.push(c); - } - /* eslint-enable @typescript-eslint/no-invalid-this */ -} - -export interface SubPopupMenuProps { - onSelect?: SelectEventHandler; - onClick?: MenuClickEventHandler; - onDeselect?: SelectEventHandler; - onOpenChange?: OpenEventHandler; - onDestroy?: DestroyEventHandler; - openKeys?: string[]; - visible?: boolean; - children?: React.ReactNode; - parentMenu?: React.ReactInstance; - eventKey?: React.Key; - store?: MiniStore; - - // adding in refactor - prefixCls?: string; - focusable?: boolean; - multiple?: boolean; - style?: React.CSSProperties; - className?: string; - defaultActiveFirst?: boolean; - activeKey?: string; - selectedKeys?: string[]; - defaultSelectedKeys?: string[]; - defaultOpenKeys?: string[]; - level?: number; - mode?: MenuMode; - triggerSubMenuAction?: TriggerSubMenuAction; - inlineIndent?: number; - manualRef?: LegacyFunctionRef; - itemIcon?: RenderIconType; - expandIcon?: RenderIconType; - - subMenuOpenDelay?: number; - subMenuCloseDelay?: number; - forceSubMenuRender?: boolean; - builtinPlacements?: BuiltinPlacements; - role?: string; - id?: string; - overflowedIndicator?: React.ReactNode; - theme?: string; - - // [Legacy] - // openTransitionName?: string; - // openAnimation?: OpenAnimation; - motion?: CSSMotionProps; - - direction?: 'ltr' | 'rtl'; -} - -export class SubPopupMenu extends React.Component { - static defaultProps = { - prefixCls: 'rc-menu', - className: '', - mode: 'vertical', - level: 1, - inlineIndent: 24, - visible: true, - focusable: true, - style: {}, - manualRef: noop, - }; - - constructor(props: SubPopupMenuProps) { - super(props); - - props.store.setState({ - activeKey: { - ...props.store.getState().activeKey, - [props.eventKey]: getActiveKey(props, props.activeKey), - }, - }); - - this.instanceArray = []; - } - - instanceArray: MenuItem[]; - - componentDidMount() { - // invoke customized ref to expose component to mixin - if (this.props.manualRef) { - this.props.manualRef(this); - } - } - - shouldComponentUpdate(nextProps: SubPopupMenuProps) { - return ( - this.props.visible || - nextProps.visible || - this.props.className !== nextProps.className || - !shallowEqual(this.props.style, nextProps.style) - ); - } - - componentDidUpdate(prevProps: SubPopupMenuProps) { - const { props } = this; - const originalActiveKey = - 'activeKey' in props - ? props.activeKey - : props.store.getState().activeKey[getEventKey(props)]; - const activeKey = getActiveKey(props, originalActiveKey); - if (activeKey !== originalActiveKey) { - updateActiveKey(props.store, getEventKey(props), activeKey); - } else if ('activeKey' in prevProps) { - // If prev activeKey is not same as current activeKey, - // we should set it. - const prevActiveKey = getActiveKey(prevProps, prevProps.activeKey); - if (activeKey !== prevActiveKey) { - updateActiveKey(props.store, getEventKey(props), activeKey); - } - } - } - - /** - * all keyboard events callbacks run from here at first - * - * note: - * This legacy code that `onKeyDown` is called by parent instead of dom self. - * which need return code to check if this event is handled - */ - onKeyDown = ( - e: React.KeyboardEvent, - callback: (item: MenuItem) => void, - ) => { - const { keyCode } = e; - let handled: boolean; - this.getFlatInstanceArray().forEach((obj: MenuItem) => { - if (obj && obj.props.active && obj.onKeyDown) { - handled = obj.onKeyDown(e); - } - }); - if (handled) { - return 1; - } - let activeItem: MenuItem = null; - if (keyCode === KeyCode.UP || keyCode === KeyCode.DOWN) { - activeItem = this.step(keyCode === KeyCode.UP ? -1 : 1); - } - if (activeItem) { - e.preventDefault(); - updateActiveKey( - this.props.store, - getEventKey(this.props), - activeItem.props.eventKey, - ); - - if (typeof callback === 'function') { - callback(activeItem); - } - - return 1; - } - return undefined; - }; - - onItemHover: HoverEventHandler = (e) => { - const { key, hover } = e; - updateActiveKey( - this.props.store, - getEventKey(this.props), - hover ? key : null, - ); - }; - - onDeselect: SelectEventHandler = (selectInfo) => { - this.props.onDeselect(selectInfo); - }; - - onSelect: SelectEventHandler = (selectInfo) => { - this.props.onSelect(selectInfo); - }; - - onClick: MenuClickEventHandler = (e) => { - this.props.onClick(e); - }; - - onOpenChange: OpenEventHandler = (e) => { - this.props.onOpenChange(e); - }; - - onDestroy: DestroyEventHandler = (key) => { - /* istanbul ignore next */ - this.props.onDestroy(key); - }; - - getFlatInstanceArray = () => this.instanceArray; - - step = (direction: number) => { - let children = this.getFlatInstanceArray(); - const activeKey = this.props.store.getState().activeKey[ - getEventKey(this.props) - ]; - const len = children.length; - if (!len) { - return null; - } - if (direction < 0) { - children = children.concat().reverse(); - } - // find current activeIndex - let activeIndex = -1; - children.every((c, ci) => { - if (c && c.props.eventKey === activeKey) { - activeIndex = ci; - return false; - } - return true; - }); - if ( - !this.props.defaultActiveFirst && - activeIndex !== -1 && - allDisabled(children.slice(activeIndex, len - 1)) - ) { - return undefined; - } - const start = (activeIndex + 1) % len; - let i = start; - - do { - const child = children[i]; - if (!child || child.props.disabled) { - i = (i + 1) % len; - } else { - return child; - } - } while (i !== start); - - return null; - }; - - renderCommonMenuItem = ( - child: React.ReactElement, - i: number, - extraProps: MenuItemProps, - ) => { - const state = this.props.store.getState(); - const { props } = this; - const key = getKeyFromChildrenIndex(child, props.eventKey, i); - const childProps = child.props; - // https://github.com/ant-design/ant-design/issues/11517#issuecomment-477403055 - if (!childProps || typeof child.type === 'string') { - return child; - } - const isActive = key === state.activeKey; - const newChildProps: MenuItemProps & - MenuItemGroupProps & - SubPopupMenuProps = { - mode: childProps.mode || props.mode, - level: props.level, - inlineIndent: props.inlineIndent, - renderMenuItem: this.renderMenuItem, - rootPrefixCls: props.prefixCls, - index: i, - parentMenu: props.parentMenu, - // customized ref function, need to be invoked manually in child's componentDidMount - manualRef: childProps.disabled - ? undefined - : (createChainedFunction( - (child as any).ref, - saveRef.bind(this), - ) as LegacyFunctionRef), - eventKey: key, - active: !childProps.disabled && isActive, - multiple: props.multiple, - onClick: (e: MenuInfo) => { - (childProps.onClick || noop)(e); - this.onClick(e); - }, - onItemHover: this.onItemHover, - motion: props.motion, - subMenuOpenDelay: props.subMenuOpenDelay, - subMenuCloseDelay: props.subMenuCloseDelay, - forceSubMenuRender: props.forceSubMenuRender, - onOpenChange: this.onOpenChange, - onDeselect: this.onDeselect, - onSelect: this.onSelect, - builtinPlacements: props.builtinPlacements, - itemIcon: childProps.itemIcon || this.props.itemIcon, - expandIcon: childProps.expandIcon || this.props.expandIcon, - ...extraProps, - direction: props.direction, - }; - // ref: https://github.com/ant-design/ant-design/issues/13943 - if (props.mode === 'inline' || isMobileDevice()) { - newChildProps.triggerSubMenuAction = 'click'; - } - return React.cloneElement(child, { - ...newChildProps, - key: key || i, - }); - }; - - renderMenuItem = ( - c: React.ReactElement, - i: number, - subMenuKey: React.Key, - ) => { - /* istanbul ignore if */ - if (!c) { - return null; - } - const state = this.props.store.getState(); - const extraProps = { - openKeys: state.openKeys, - selectedKeys: state.selectedKeys, - triggerSubMenuAction: this.props.triggerSubMenuAction, - subMenuKey, - }; - return this.renderCommonMenuItem(c, i, extraProps); - }; - - render() { - const { ...props } = this.props; - this.instanceArray = []; - const className = classNames( - props.prefixCls, - props.className, - `${props.prefixCls}-${props.mode}`, - ); - const domProps: React.HTMLAttributes = { - className, - // role could be 'select' and by default set to menu - role: props.role || 'menu', - }; - if (props.id) { - domProps.id = props.id; - } - if (props.focusable) { - domProps.tabIndex = 0; - domProps.onKeyDown = this.onKeyDown as any; - } - const { - prefixCls, - eventKey, - visible, - level, - mode, - overflowedIndicator, - theme, - } = props; - menuAllProps.forEach((key) => delete props[key]); - - // Otherwise, the propagated click event will trigger another onClick - delete props.onClick; - - return ( - - {toArray(props.children).map((c: React.ReactElement, i) => - this.renderMenuItem(c, i, eventKey || '0-menu-'), - )} - - ); - } -} -const connected = (connect()( - SubPopupMenu as any, -) as unknown) as React.ComponentClass & { - getWrappedInstance: () => SubPopupMenu; -}; - -export default connected; diff --git a/src/context/IdContext.ts b/src/context/IdContext.ts new file mode 100644 index 00000000..8190bdec --- /dev/null +++ b/src/context/IdContext.ts @@ -0,0 +1,15 @@ +import * as React from 'react'; + +export const IdContext = React.createContext(null); + +export function getMenuId(uuid: string, eventKey: string) { + return `${uuid}-${eventKey}`; +} + +/** + * Get `data-menu-id` + */ +export function useMenuId(eventKey: string) { + const id = React.useContext(IdContext); + return getMenuId(id, eventKey); +} diff --git a/src/context/MeasureContext.tsx b/src/context/MeasureContext.tsx new file mode 100644 index 00000000..ff870422 --- /dev/null +++ b/src/context/MeasureContext.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; + +const EmptyList: string[] = []; + +// ========================= Path Register ========================= +export interface PathRegisterContextProps { + registerPath: (key: string, keyPath: string[]) => void; + unregisterPath: (key: string, keyPath: string[]) => void; +} + +export const PathRegisterContext = React.createContext( + null, +); + +export function useMeasure() { + return React.useContext(PathRegisterContext); +} + +// ========================= Path Connect ========================== +export const PathConnectContext = React.createContext(EmptyList); + +export function useKeyPath(eventKey: string) { + const parentKeyPath = React.useContext(PathConnectContext); + return React.useMemo(() => [...parentKeyPath, eventKey], [ + parentKeyPath, + eventKey, + ]); +} + +// =========================== Path User =========================== +export interface PathUserContextProps { + isSubPathKey: (pathKeys: string[], eventKey: string) => boolean; + getKeyPath: (eventKey: string) => string[]; +} + +export const PathUserContext = React.createContext(null); diff --git a/src/context/MenuContext.tsx b/src/context/MenuContext.tsx new file mode 100644 index 00000000..f67090ea --- /dev/null +++ b/src/context/MenuContext.tsx @@ -0,0 +1,100 @@ +import * as React from 'react'; +import type { CSSMotionProps } from 'rc-motion'; +import useMemo from 'rc-util/lib/hooks/useMemo'; +import shallowEqual from 'shallowequal'; +import type { + BuiltinPlacements, + MenuClickEventHandler, + MenuMode, + RenderIconType, + TriggerSubMenuAction, +} from '../interface'; + +export interface MenuContextProps { + prefixCls: string; + openKeys: string[]; + rtl?: boolean; + + // Mode + mode: MenuMode; + + // Disabled + disabled?: boolean; + // Used for overflow only. Prevent hidden node trigger open + overflowDisabled?: boolean; + + // Active + activeKey: string; + onActive: (key: string) => void; + onInactive: (key: string) => void; + + // Selection + selectedKeys: string[]; + + // Level + inlineIndent: number; + + // Motion + motion?: CSSMotionProps; + defaultMotions?: Partial<{ [key in MenuMode | 'other']: CSSMotionProps }>; + + // Popup + subMenuOpenDelay: number; + subMenuCloseDelay: number; + forceSubMenuRender?: boolean; + builtinPlacements?: BuiltinPlacements; + triggerSubMenuAction?: TriggerSubMenuAction; + + // Icon + itemIcon?: RenderIconType; + expandIcon?: RenderIconType; + + // Function + onItemClick: MenuClickEventHandler; + onOpenChange: (key: string, open: boolean) => void; + getPopupContainer: (node: HTMLElement) => HTMLElement; +} + +export const MenuContext = React.createContext(null); + +function mergeProps( + origin: MenuContextProps, + target: Partial, +): MenuContextProps { + const clone = { ...origin }; + + Object.keys(target).forEach(key => { + const value = target[key]; + if (value !== undefined) { + clone[key] = value; + } + }); + + return clone; +} + +export interface InheritableContextProps extends Partial { + children?: React.ReactNode; + locked?: boolean; +} + +export default function InheritableContextProvider({ + children, + locked, + ...restProps +}: InheritableContextProps) { + const context = React.useContext(MenuContext); + + const inheritableContext = useMemo( + () => mergeProps(context, restProps), + [context, restProps], + (prev, next) => + !locked && (prev[0] !== next[0] || !shallowEqual(prev[1], next[1])), + ); + + return ( + + {children} + + ); +} diff --git a/src/hooks/useAccessibility.ts b/src/hooks/useAccessibility.ts new file mode 100644 index 00000000..33c42942 --- /dev/null +++ b/src/hooks/useAccessibility.ts @@ -0,0 +1,359 @@ +import * as React from 'react'; +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 { getMenuId } from '../context/IdContext'; + +// destruct to reduce minify size +const { LEFT, RIGHT, UP, DOWN, ENTER, ESC } = KeyCode; + +const ArrowKeys = [UP, DOWN, LEFT, RIGHT]; + +function getOffset( + mode: MenuMode, + isRootLevel: boolean, + isRtl: boolean, + which: number, +): { + offset?: number; + sibling?: boolean; + inlineTrigger?: boolean; +} { + const prev = 'prev' as const; + const next = 'next' as const; + const children = 'children' as const; + const parent = 'parent' as const; + + // Inline enter is special that we use unique operation + if (mode === 'inline' && which === ENTER) { + return { + inlineTrigger: true, + }; + } + + type OffsetMap = Record; + const inline: OffsetMap = { + [UP]: prev, + [DOWN]: next, + }; + const horizontal: OffsetMap = { + [LEFT]: isRtl ? next : prev, + [RIGHT]: isRtl ? prev : next, + [DOWN]: children, + [ENTER]: children, + }; + const vertical: OffsetMap = { + [UP]: prev, + [DOWN]: next, + [ENTER]: children, + [ESC]: parent, + [LEFT]: isRtl ? children : parent, + [RIGHT]: isRtl ? parent : children, + }; + + const offsets: Record< + string, + Record + > = { + inline, + horizontal, + vertical, + inlineSub: inline, + horizontalSub: vertical, + verticalSub: vertical, + }; + + const type = offsets[`${mode}${isRootLevel ? '' : 'Sub'}`]?.[which]; + + switch (type) { + case prev: + return { + offset: -1, + sibling: true, + }; + + case next: + return { + offset: 1, + sibling: true, + }; + + case parent: + return { + offset: -1, + sibling: false, + }; + + case children: + return { + offset: 1, + sibling: false, + }; + + default: + return null; + } +} + +function findContainerUL(element: HTMLElement): HTMLUListElement { + let current: HTMLElement = element; + while (current) { + if (current.getAttribute('data-menu-list')) { + return current as HTMLUListElement; + } + + current = current.parentElement; + } + + // Normally should not reach this line + /* istanbul ignore next */ + return null; +} + +/** + * Find focused element within element set provided + */ +function getFocusElement( + activeElement: HTMLElement, + elements: Set, +): HTMLElement { + let current = activeElement || document.activeElement; + + while (current) { + if (elements.has(current as any)) { + return current as HTMLElement; + } + + current = current.parentElement; + } + + return null; +} + +/** + * Get focusable elements from the element set under provided container + */ +function getFocusableElements( + container: HTMLElement, + elements: Set, +) { + const list = getFocusNodeList(container, true); + return list.filter(ele => elements.has(ele)); +} + +function getNextFocusElement( + parentQueryContainer: HTMLElement, + elements: Set, + focusMenuElement?: HTMLElement, + offset: number = 1, +) { + // Key on the menu item will not get validate parent container + if (!parentQueryContainer) { + return null; + } + + // List current level menu item elements + const sameLevelFocusableMenuElementList = getFocusableElements( + parentQueryContainer, + elements, + ); + + // Find next focus index + const count = sameLevelFocusableMenuElementList.length; + let focusIndex = sameLevelFocusableMenuElementList.findIndex( + ele => focusMenuElement === ele, + ); + + if (offset < 0) { + if (focusIndex === -1) { + focusIndex = count - 1; + } else { + focusIndex -= 1; + } + } else if (offset > 0) { + focusIndex += 1; + } + + focusIndex = (focusIndex + count) % count; + + // Focus menu item + return sameLevelFocusableMenuElementList[focusIndex]; +} + +export default function useAccessibility( + mode: MenuMode, + activeKey: string, + isRtl: boolean, + id: string, + + containerRef: React.RefObject, + getKeys: () => string[], + getKeyPath: (key: string, includeOverflow?: boolean) => string[], + + triggerActiveKey: (key: string) => void, + triggerAccessibilityOpen: (key: string, open?: boolean) => void, + + originOnKeyDown?: React.KeyboardEventHandler, +): React.KeyboardEventHandler { + const rafRef = React.useRef(); + + const activeRef = React.useRef(); + activeRef.current = activeKey; + + const cleanRaf = () => { + raf.cancel(rafRef.current); + }; + + React.useEffect( + () => () => { + cleanRaf(); + }, + [], + ); + + return e => { + const { which } = e; + + if ([...ArrowKeys, ENTER, ESC].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); + } + }); + + return elements; + }; + + refreshElements(); + + // First we should find current focused MenuItem/SubMenu element + const activeElement = key2element.get(activeKey); + const focusMenuElement = getFocusElement(activeElement, elements); + const focusMenuKey = element2key.get(focusMenuElement); + + const offsetObj = getOffset( + mode, + getKeyPath(focusMenuKey, true).length === 1, + isRtl, + which, + ); + + // Some mode do not have fully arrow operation like inline + if (!offsetObj) { + return; + } + + // Arrow prevent default to avoid page scroll + if (ArrowKeys.includes(which)) { + e.preventDefault(); + } + + const tryFocus = (menuElement: HTMLElement) => { + if (menuElement) { + let focusTargetElement = menuElement; + + // Focus to link instead of menu item if possible + const link = menuElement.querySelector('a'); + if (link?.getAttribute('href')) { + focusTargetElement = link; + } + + const targetKey = element2key.get(menuElement); + triggerActiveKey(targetKey); + + /** + * Do not `useEffect` here since `tryFocus` may trigger async + * which makes React sync update the `activeKey` + * that force render before `useRef` set the next activeKey + */ + cleanRaf(); + rafRef.current = raf(() => { + if (activeRef.current === targetKey) { + focusTargetElement.focus(); + } + }); + } + }; + + if (offsetObj.sibling || !focusMenuElement) { + // ========================== Sibling ========================== + // Find walkable focus menu element container + let parentQueryContainer: HTMLElement; + if (!focusMenuElement || mode === 'inline') { + parentQueryContainer = containerRef.current; + } else { + parentQueryContainer = findContainerUL(focusMenuElement); + } + + // Get next focus element + const targetElement = getNextFocusElement( + parentQueryContainer, + elements, + focusMenuElement, + offsetObj.offset, + ); + + // Focus menu item + tryFocus(targetElement); + + // ======================= InlineTrigger ======================= + } else if (offsetObj.inlineTrigger) { + // Inline trigger no need switch to sub menu item + triggerAccessibilityOpen(focusMenuKey); + // =========================== Level =========================== + } else if (offsetObj.offset > 0) { + triggerAccessibilityOpen(focusMenuKey, true); + + cleanRaf(); + rafRef.current = raf(() => { + // Async should resync elements + refreshElements(); + + const controlId = focusMenuElement.getAttribute('aria-controls'); + const subQueryContainer = document.getElementById(controlId); + + // Get sub focusable menu item + const targetElement = getNextFocusElement( + subQueryContainer, + elements, + ); + + // Focus menu item + tryFocus(targetElement); + }, 5); + } else if (offsetObj.offset < 0) { + const keyPath = getKeyPath(focusMenuKey, true); + const parentKey = keyPath[keyPath.length - 2]; + + const parentMenuElement = key2element.get(parentKey); + + // Focus menu item + triggerAccessibilityOpen(parentKey, false); + tryFocus(parentMenuElement); + } + } + + // Pass origin key down event + originOnKeyDown?.(e); + }; +} diff --git a/src/hooks/useActive.ts b/src/hooks/useActive.ts new file mode 100644 index 00000000..dd0d6005 --- /dev/null +++ b/src/hooks/useActive.ts @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { MenuContext } from '../context/MenuContext'; +import type { MenuHoverEventHandler } from '../interface'; + +interface ActiveObj { + active: boolean; + onMouseEnter?: React.MouseEventHandler; + onMouseLeave?: React.MouseEventHandler; +} + +export default function useActive( + eventKey: string, + disabled: boolean, + onMouseEnter?: MenuHoverEventHandler, + onMouseLeave?: MenuHoverEventHandler, +): ActiveObj { + const { + // Active + activeKey, + onActive, + onInactive, + } = React.useContext(MenuContext); + + const ret: ActiveObj = { + active: activeKey === eventKey, + }; + + // Skip when disabled + if (!disabled) { + ret.onMouseEnter = domEvent => { + onMouseEnter?.({ + key: eventKey, + domEvent, + }); + onActive(eventKey); + }; + ret.onMouseLeave = domEvent => { + onMouseLeave?.({ + key: eventKey, + domEvent, + }); + onInactive(eventKey); + }; + } + + return ret; +} diff --git a/src/hooks/useDirectionStyle.ts b/src/hooks/useDirectionStyle.ts new file mode 100644 index 00000000..26967ad1 --- /dev/null +++ b/src/hooks/useDirectionStyle.ts @@ -0,0 +1,15 @@ +import * as React from 'react'; +import { MenuContext } from '../context/MenuContext'; + +export default function useDirectionStyle(level: number): React.CSSProperties { + const { mode, rtl, inlineIndent } = React.useContext(MenuContext); + + if (mode !== 'inline') { + return null; + } + + const len = level; + return rtl + ? { paddingRight: len * inlineIndent } + : { paddingLeft: len * inlineIndent }; +} diff --git a/src/hooks/useKeyRecords.ts b/src/hooks/useKeyRecords.ts new file mode 100644 index 00000000..1a62f312 --- /dev/null +++ b/src/hooks/useKeyRecords.ts @@ -0,0 +1,115 @@ +import * as React from 'react'; +import { useRef, useCallback } from 'react'; +import warning from 'rc-util/lib/warning'; +import { nextSlice } from '../utils/timeUtil'; + +const PATH_SPLIT = '__RC_UTIL_PATH_SPLIT__'; + +const getPathStr = (keyPath: string[]) => keyPath.join(PATH_SPLIT); +const getPathKeys = (keyPathStr: string) => keyPathStr.split(PATH_SPLIT); + +export const OVERFLOW_KEY = 'rc-menu-more'; + +export default function useKeyRecords() { + const [, forceUpdate] = React.useState({}); + const key2pathRef = useRef(new Map()); + const path2keyRef = useRef(new Map()); + const [overflowKeys, setOverflowKeys] = React.useState([]); + const updateRef = useRef(0); + + const registerPath = useCallback((key: string, keyPath: string[]) => { + // Warning for invalidate or duplicated `key` + if (process.env.NODE_ENV !== 'production') { + warning( + !key2pathRef.current.has(key), + `Duplicated key '${key}' used in Menu by path [${keyPath.join(' > ')}]`, + ); + } + + // Fill map + const connectedPath = getPathStr(keyPath); + path2keyRef.current.set(connectedPath, key); + key2pathRef.current.set(key, connectedPath); + + updateRef.current += 1; + const id = updateRef.current; + + nextSlice(() => { + if (id === updateRef.current) { + forceUpdate({}); + } + }); + }, []); + + const unregisterPath = useCallback((key: string, keyPath: string[]) => { + const connectedPath = getPathStr(keyPath); + path2keyRef.current.delete(connectedPath); + key2pathRef.current.delete(key); + }, []); + + const refreshOverflowKeys = useCallback((keys: string[]) => { + setOverflowKeys(keys); + }, []); + + const getKeyPath = useCallback( + (eventKey: string, includeOverflow?: boolean) => { + const fullPath = key2pathRef.current.get(eventKey) || ''; + const keys = getPathKeys(fullPath); + + if (includeOverflow && overflowKeys.includes(keys[0])) { + keys.unshift(OVERFLOW_KEY); + } + + return keys; + }, + [overflowKeys], + ); + + const isSubPathKey = useCallback( + (pathKeys: string[], eventKey: string) => + pathKeys.some(pathKey => { + const pathKeyList = getKeyPath(pathKey, true); + + return pathKeyList.includes(eventKey); + }), + [getKeyPath], + ); + + const getKeys = () => { + const keys = [...key2pathRef.current.keys()]; + + if (overflowKeys.length) { + keys.push(OVERFLOW_KEY); + } + + return keys; + }; + + /** + * Find current key related child path keys + */ + const getSubPathKeys = useCallback((key: string): Set => { + const connectedPath = `${key2pathRef.current.get(key)}${PATH_SPLIT}`; + const pathKeys = new Set(); + + [...path2keyRef.current.keys()].forEach(pathKey => { + if (pathKey.startsWith(connectedPath)) { + pathKeys.add(path2keyRef.current.get(pathKey)); + } + }); + return pathKeys; + }, []); + + return { + // Register + registerPath, + unregisterPath, + refreshOverflowKeys, + + // Util + isSubPathKey, + getKeyPath, + getKeys, + getSubPathKeys, + }; +} diff --git a/src/hooks/useMemoCallback.ts b/src/hooks/useMemoCallback.ts new file mode 100644 index 00000000..3555dea5 --- /dev/null +++ b/src/hooks/useMemoCallback.ts @@ -0,0 +1,19 @@ +import * as React from 'react'; + +/** + * Cache callback function that always return same ref instead. + * This is used for context optimization. + */ +export default function useMemoCallback void>( + func: T, +): T { + const funRef = React.useRef(func); + funRef.current = func; + + const callback = React.useCallback( + ((...args: any[]) => funRef.current?.(...args)) as any, + [], + ); + + return func ? callback : undefined; +} diff --git a/src/hooks/useUUID.ts b/src/hooks/useUUID.ts new file mode 100644 index 00000000..cce514ee --- /dev/null +++ b/src/hooks/useUUID.ts @@ -0,0 +1,23 @@ +import * as React from 'react'; +import useMergedState from 'rc-util/lib/hooks/useMergedState'; + +const uniquePrefix = Math.random().toFixed(5).toString().slice(2); + +let internalId = 0; + +export default function useUUID(id?: string) { + const [uuid, setUUID] = useMergedState(id, { + value: id, + }); + + React.useEffect(() => { + internalId += 1; + const newId = + process.env.NODE_ENV === 'test' + ? 'test' + : `${uniquePrefix}-${internalId}`; + setUUID(`rc-menu-uuid-${newId}`); + }, []); + + return uuid; +} diff --git a/src/index.tsx b/src/index.ts similarity index 55% rename from src/index.tsx rename to src/index.ts index 6d93f39e..3da4f91b 100644 --- a/src/index.tsx +++ b/src/index.ts @@ -1,6 +1,6 @@ import Menu, { MenuProps } from './Menu'; -import SubMenu, { SubMenuProps } from './SubMenu'; import MenuItem, { MenuItemProps } from './MenuItem'; +import SubMenu, { SubMenuProps } from './SubMenu'; import MenuItemGroup, { MenuItemGroupProps } from './MenuItemGroup'; import Divider from './Divider'; @@ -17,4 +17,18 @@ export { MenuItemGroupProps, }; -export default Menu; +type MenuType = typeof Menu & { + Item: typeof MenuItem; + SubMenu: typeof SubMenu; + ItemGroup: typeof MenuItemGroup; + Divider: typeof Divider; +}; + +const ExportMenu = Menu as MenuType; + +ExportMenu.Item = MenuItem; +ExportMenu.SubMenu = SubMenu; +ExportMenu.ItemGroup = MenuItemGroup; +ExportMenu.Divider = Divider; + +export default ExportMenu; diff --git a/src/interface.ts b/src/interface.ts index feecd216..ace7673d 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -1,61 +1,48 @@ +import type * as React from 'react'; + +// ========================== Basic ========================== +export type MenuMode = 'horizontal' | 'vertical' | 'inline'; + +export type BuiltinPlacements = Record; + +export type TriggerSubMenuAction = 'click' | 'hover'; + +export interface RenderIconInfo { + isSelected?: boolean; + isOpen?: boolean; + isSubMenu?: boolean; + disabled?: boolean; +} + export type RenderIconType = | React.ReactNode - | ((props: any) => React.ReactNode); + | ((props: RenderIconInfo) => React.ReactNode); export interface MenuInfo { - key: React.Key; - keyPath: React.Key[]; + key: string; + keyPath: string[]; + /** @deprecated This will not support in future. You should avoid to use this */ item: React.ReactInstance; - domEvent: React.MouseEvent; -} -export interface SelectInfo extends MenuInfo { - selectedKeys?: React.Key[]; + domEvent: React.MouseEvent | React.KeyboardEvent; } -export type SelectEventHandler = (info: SelectInfo) => void; - -export type HoverEventHandler = (info: { - key: React.Key; - hover: boolean; -}) => void; +export interface MenuTitleInfo { + key: string; + domEvent: React.MouseEvent | React.KeyboardEvent; +} +// ========================== Hover ========================== export type MenuHoverEventHandler = (info: { - key: React.Key; + key: string; domEvent: React.MouseEvent; }) => void; -export type MenuClickEventHandler = (info: MenuInfo) => void; - -export type DestroyEventHandler = (key: React.Key) => void; - -export type OpenEventHandler = ( - keys: - | React.Key[] - | { - key: React.Key; - item: React.ReactInstance; - trigger: string; - open: boolean; - }, -) => void; - -export type MenuMode = - | 'horizontal' - | 'vertical' - | 'vertical-left' - | 'vertical-right' - | 'inline'; - -export type OpenAnimation = string | Record; - -export interface MiniStore { - getState: () => any; - setState: (state: any) => void; - subscribe: (listener: () => void) => () => void; +// ======================== Selection ======================== +export interface SelectInfo extends MenuInfo { + selectedKeys: string[]; } -export type LegacyFunctionRef = (node: React.ReactInstance) => void; - -export type BuiltinPlacements = Record; +export type SelectEventHandler = (info: SelectInfo) => void; -export type TriggerSubMenuAction = 'click' | 'hover'; +// ========================== Click ========================== +export type MenuClickEventHandler = (info: MenuInfo) => void; diff --git a/src/util.ts b/src/util.ts deleted file mode 100644 index 9f1ab844..00000000 --- a/src/util.ts +++ /dev/null @@ -1,166 +0,0 @@ -import * as React from 'react'; -import isMobile from './utils/isMobile'; -import type MenuItemGroup from './MenuItemGroup'; -import type SubMenu from './SubMenu'; -import type MenuItem from './MenuItem'; - -export function noop() {} - -export function getKeyFromChildrenIndex( - child: React.ReactElement, - menuEventKey: React.Key, - index: number, -): React.Key { - const prefix = menuEventKey || ''; - return child.key || `${prefix}item_${index}`; -} - -export function getMenuIdFromSubMenuEventKey(eventKey: string): React.Key { - return `${eventKey}-menu-`; -} - -export function loopMenuItem( - children: React.ReactNode, - cb: (node: React.ReactElement, index: number) => void, -) { - let index = -1; - React.Children.forEach(children, (c: React.ReactElement) => { - index += 1; - if (c && c.type && (c.type as typeof MenuItemGroup).isMenuItemGroup) { - React.Children.forEach(c.props.children, (c2: React.ReactElement) => { - index += 1; - cb(c2, index); - }); - } else { - cb(c, index); - } - }); -} - -export function loopMenuItemRecursively( - children: React.ReactNode, - keys: string[], - ret: { find: boolean }, -) { - /* istanbul ignore if */ - if (!children || ret.find) { - return; - } - React.Children.forEach(children, (c: React.ReactElement) => { - if (c) { - const construct = c.type as - | typeof MenuItemGroup - | typeof MenuItem - | typeof SubMenu; - if ( - !construct || - !( - construct.isSubMenu || - construct.isMenuItem || - construct.isMenuItemGroup - ) - ) { - return; - } - if (keys.indexOf((c as any).key) !== -1) { - // eslint-disable-next-line no-param-reassign - ret.find = true; - } else if (c.props.children) { - loopMenuItemRecursively(c.props.children, keys, ret); - } - } - }); -} - -export const menuAllProps = [ - 'defaultSelectedKeys', - 'selectedKeys', - 'defaultOpenKeys', - 'openKeys', - 'mode', - 'getPopupContainer', - 'onSelect', - 'onDeselect', - 'onDestroy', - 'openTransitionName', - 'openAnimation', - 'subMenuOpenDelay', - 'subMenuCloseDelay', - 'forceSubMenuRender', - 'triggerSubMenuAction', - 'level', - 'selectable', - 'multiple', - 'onOpenChange', - 'visible', - 'focusable', - 'defaultActiveFirst', - 'prefixCls', - 'inlineIndent', - 'parentMenu', - 'title', - 'rootPrefixCls', - 'eventKey', - 'active', - 'onItemHover', - 'onTitleMouseEnter', - 'onTitleMouseLeave', - 'onTitleClick', - 'popupAlign', - 'popupOffset', - 'isOpen', - 'renderMenuItem', - 'manualRef', - 'subMenuKey', - 'disabled', - 'index', - 'isSelected', - 'store', - 'activeKey', - 'builtinPlacements', - 'overflowedIndicator', - 'motion', - - // the following keys found need to be removed from test regression - 'attribute', - 'value', - 'popupClassName', - 'inlineCollapsed', - 'menu', - 'theme', - 'itemIcon', - 'expandIcon', -]; - -// ref: https://github.com/ant-design/ant-design/issues/14007 -// ref: https://bugs.chromium.org/p/chromium/issues/detail?id=360889 -// getBoundingClientRect return the full precision value, which is -// not the same behavior as on chrome. Set the precision to 6 to -// unify their behavior -export const getWidth = (elem: HTMLElement, includeMargin: boolean = false) => { - let width = - elem && - typeof elem.getBoundingClientRect === 'function' && - elem.getBoundingClientRect().width; - if (width) { - if (includeMargin) { - const { marginLeft, marginRight } = getComputedStyle(elem); - width += +marginLeft.replace('px', '') + +marginRight.replace('px', ''); - } - width = +width.toFixed(6); - } - return width || 0; -}; - -export const setStyle = ( - elem: HTMLElement, - styleProperty: keyof React.CSSProperties, - value: string | number, -) => { - if (elem && typeof elem.style === 'object') { - // eslint-disable-next-line no-param-reassign - elem.style[styleProperty] = value; - } -}; - -export const isMobileDevice = (): boolean => isMobile.any; diff --git a/src/utils/isMobile.ts b/src/utils/isMobile.ts deleted file mode 100644 index d70203ff..00000000 --- a/src/utils/isMobile.ts +++ /dev/null @@ -1,121 +0,0 @@ -// MIT License from https://github.com/kaimallea/isMobile - -const applePhone = /iPhone/i; -const appleIpod = /iPod/i; -const appleTablet = /iPad/i; -const androidPhone = /\bAndroid(?:.+)Mobile\b/i; // Match 'Android' AND 'Mobile' -const androidTablet = /Android/i; -const amazonPhone = /\bAndroid(?:.+)SD4930UR\b/i; -const amazonTablet = /\bAndroid(?:.+)(?:KF[A-Z]{2,4})\b/i; -const windowsPhone = /Windows Phone/i; -const windowsTablet = /\bWindows(?:.+)ARM\b/i; // Match 'Windows' AND 'ARM' -const otherBlackberry = /BlackBerry/i; -const otherBlackberry10 = /BB10/i; -const otherOpera = /Opera Mini/i; -const otherChrome = /\b(CriOS|Chrome)(?:.+)Mobile/i; -const otherFirefox = /Mobile(?:.+)Firefox\b/i; // Match 'Mobile' AND 'Firefox' - -function match(regex, userAgent) { - return regex.test(userAgent); -} - -function isMobile(userAgent?: string) { - let ua = - userAgent || (typeof navigator !== 'undefined' ? navigator.userAgent : ''); - - // Facebook mobile app's integrated browser adds a bunch of strings that - // match everything. Strip it out if it exists. - let tmp = ua.split('[FBAN'); - if (typeof tmp[1] !== 'undefined') { - [ua] = tmp; - } - - // Twitter mobile app's integrated browser on iPad adds a "Twitter for - // iPhone" string. Same probably happens on other tablet platforms. - // This will confuse detection so strip it out if it exists. - tmp = ua.split('Twitter'); - if (typeof tmp[1] !== 'undefined') { - [ua] = tmp; - } - - const result = { - apple: { - phone: match(applePhone, ua) && !match(windowsPhone, ua), - ipod: match(appleIpod, ua), - tablet: - !match(applePhone, ua) && - match(appleTablet, ua) && - !match(windowsPhone, ua), - device: - (match(applePhone, ua) || - match(appleIpod, ua) || - match(appleTablet, ua)) && - !match(windowsPhone, ua), - }, - amazon: { - phone: match(amazonPhone, ua), - tablet: !match(amazonPhone, ua) && match(amazonTablet, ua), - device: match(amazonPhone, ua) || match(amazonTablet, ua), - }, - android: { - phone: - (!match(windowsPhone, ua) && match(amazonPhone, ua)) || - (!match(windowsPhone, ua) && match(androidPhone, ua)), - tablet: - !match(windowsPhone, ua) && - !match(amazonPhone, ua) && - !match(androidPhone, ua) && - (match(amazonTablet, ua) || match(androidTablet, ua)), - device: - (!match(windowsPhone, ua) && - (match(amazonPhone, ua) || - match(amazonTablet, ua) || - match(androidPhone, ua) || - match(androidTablet, ua))) || - match(/\bokhttp\b/i, ua), - }, - windows: { - phone: match(windowsPhone, ua), - tablet: match(windowsTablet, ua), - device: match(windowsPhone, ua) || match(windowsTablet, ua), - }, - other: { - blackberry: match(otherBlackberry, ua), - blackberry10: match(otherBlackberry10, ua), - opera: match(otherOpera, ua), - firefox: match(otherFirefox, ua), - chrome: match(otherChrome, ua), - device: - match(otherBlackberry, ua) || - match(otherBlackberry10, ua) || - match(otherOpera, ua) || - match(otherFirefox, ua) || - match(otherChrome, ua), - }, - - // Additional - any: null, - phone: null, - tablet: null, - }; - result.any = - result.apple.device || - result.android.device || - result.windows.device || - result.other.device; - - // excludes 'other' devices and ipods, targeting touchscreen phones - result.phone = - result.apple.phone || result.android.phone || result.windows.phone; - result.tablet = - result.apple.tablet || result.android.tablet || result.windows.tablet; - - return result; -} - -const defaultResult = { - ...isMobile(), - isMobile, -}; - -export default defaultResult; diff --git a/src/utils/legacyUtil.ts b/src/utils/legacyUtil.ts deleted file mode 100644 index 7e885864..00000000 --- a/src/utils/legacyUtil.ts +++ /dev/null @@ -1,58 +0,0 @@ -import warning from 'rc-util/lib/warning'; -import type { CSSMotionProps } from 'rc-motion'; -import type { OpenAnimation, MenuMode } from '../interface'; - -interface GetMotionProps { - motion?: CSSMotionProps; - defaultMotions?: Partial<{ [key in MenuMode | 'other']: CSSMotionProps }>; - openAnimation?: OpenAnimation; - openTransitionName?: string; - prefixCls?: string; -} - -interface GetMotionState { - switchingModeFromInline: boolean; -} - -export function getMotion( - { - prefixCls, - motion, - defaultMotions = {}, - openAnimation, - openTransitionName, - }: GetMotionProps, - { switchingModeFromInline }: GetMotionState, - menuMode: MenuMode, -): CSSMotionProps { - if (motion) { - return motion; - } - - if (typeof openAnimation === 'object' && openAnimation) { - warning( - false, - 'Object type of `openAnimation` is removed. Please use `motion` instead.', - ); - } else if (typeof openAnimation === 'string') { - return { - motionName: `${prefixCls}-open-${openAnimation}`, - }; - } - - if (openTransitionName) { - return { - motionName: openTransitionName, - }; - } - // Default logic - const defaultMotion = defaultMotions[menuMode]; - - if (defaultMotion) { - return defaultMotion; - } - - // When mode switch from inline - // submenu should hide without animation - return switchingModeFromInline ? null : defaultMotions.other; -} diff --git a/src/utils/motionUtil.ts b/src/utils/motionUtil.ts new file mode 100644 index 00000000..09042d07 --- /dev/null +++ b/src/utils/motionUtil.ts @@ -0,0 +1,17 @@ +import type { CSSMotionProps } from 'rc-motion'; + +export function getMotion( + mode: string, + motion?: CSSMotionProps, + defaultMotions?: Record, +) { + if (motion) { + return motion; + } + + if (defaultMotions) { + return defaultMotions[mode] || defaultMotions.other; + } + + return undefined; +} diff --git a/src/utils/nodeUtil.ts b/src/utils/nodeUtil.ts new file mode 100644 index 00000000..1ce0a9be --- /dev/null +++ b/src/utils/nodeUtil.ts @@ -0,0 +1,28 @@ +import * as React from 'react'; +import warning from 'rc-util/lib/warning'; +import toArray from 'rc-util/lib/Children/toArray'; + +export function parseChildren(children: React.ReactNode, keyPath: string[]) { + return toArray(children).map((child, index) => { + if (React.isValidElement(child)) { + let { key } = child; + if (key === null || key === undefined) { + key = `tmp_key-${[...keyPath, index].join('-')}`; + + if (process.env.NODE_ENV !== 'production') { + warning( + false, + 'MenuItem or SubMenu should not leave undefined `key`.', + ); + } + } + + return React.cloneElement(child, { + key, + eventKey: key, + } as any); + } + + return child; + }); +} diff --git a/src/utils/timeUtil.ts b/src/utils/timeUtil.ts new file mode 100644 index 00000000..6b381ebb --- /dev/null +++ b/src/utils/timeUtil.ts @@ -0,0 +1,14 @@ +import { act } from 'react-dom/test-utils'; + +export function nextSlice(callback: () => void) { + if (process.env.NODE_ENV === 'test') { + Promise.resolve().then(() => { + act(() => { + callback(); + }); + }); + } else { + /* istanbul ignore next */ + Promise.resolve().then(callback); + } +} diff --git a/src/utils/warnUtil.ts b/src/utils/warnUtil.ts new file mode 100644 index 00000000..afd5c3cf --- /dev/null +++ b/src/utils/warnUtil.ts @@ -0,0 +1,23 @@ +import warning from 'rc-util/lib/warning'; + +/** + * `onClick` event return `info.item` which point to react node directly. + * We should warning this since it will not work on FC. + */ +export function warnItemProp({ + item, + ...restInfo +}: T): T { + Object.defineProperty(restInfo, 'item', { + get: () => { + warning( + false, + '`info.item` is deprecated since we will move to function component that not provides React Node instance in future.', + ); + + return item; + }, + }); + + return restInfo as T; +} diff --git a/tests/Collapsed.spec.js b/tests/Collapsed.spec.js index a4979bd1..7992f23a 100644 --- a/tests/Collapsed.spec.js +++ b/tests/Collapsed.spec.js @@ -24,25 +24,23 @@ describe('Menu.Collapsed', () => { menu2 , ); + + // Inline expect( - wrapper - .find('ul.rc-menu-sub') - .at(0) - .hasClass('rc-menu-hidden'), + wrapper.find('ul.rc-menu-sub').at(0).hasClass('rc-menu-hidden'), ).toBe(false); + + // Vertical wrapper.setProps({ mode: 'vertical' }); expect( - wrapper - .find('ul.rc-menu-sub') - .at(0) - .hasClass('rc-menu-hidden'), + wrapper.find('ul.rc-menu-sub').at(0).hasClass('rc-menu-hidden'), ).toBe(false); + + // Inline wrapper.setProps({ mode: 'inline' }); + wrapper.update(); expect( - wrapper - .find('ul.rc-menu-sub') - .at(0) - .hasClass('rc-menu-hidden'), + wrapper.find('ul.rc-menu-sub').at(0).hasClass('rc-menu-hidden'), ).toBe(false); }); @@ -59,23 +57,21 @@ describe('Menu.Collapsed', () => { , ); expect( - wrapper - .find('ul.rc-menu-sub') - .at(0) - .hasClass('rc-menu-inline'), + wrapper.find('ul.rc-menu-sub').at(0).hasClass('rc-menu-inline'), ).toBe(true); expect( - wrapper - .find('ul.rc-menu-sub') - .at(0) - .hasClass('rc-menu-hidden'), + wrapper.find('ul.rc-menu-sub').at(0).hasClass('rc-menu-hidden'), ).toBe(false); wrapper.setProps({ inlineCollapsed: true }); // 动画结束后套样式; - jest.runAllTimers(); - wrapper.update(); - wrapper.simulate('transitionEnd', { propertyName: 'width' }); + act(() => { + jest.runAllTimers(); + wrapper.update(); + }); + wrapper + .find('Overflow') + .simulate('transitionEnd', { propertyName: 'width' }); // Flush SubMenu raf state update act(() => { @@ -84,28 +80,21 @@ describe('Menu.Collapsed', () => { }); expect( - wrapper - .find('ul.rc-menu-root') - .at(0) - .hasClass('rc-menu-vertical'), + wrapper.find('ul.rc-menu-root').at(0).hasClass('rc-menu-vertical'), ).toBe(true); expect(wrapper.find('ul.rc-menu-sub').length).toBe(0); wrapper.setProps({ inlineCollapsed: false }); - jest.runAllTimers(); - wrapper.update(); + act(() => { + jest.runAllTimers(); + wrapper.update(); + }); expect( - wrapper - .find('ul.rc-menu-sub') - .at(0) - .hasClass('rc-menu-inline'), + wrapper.find('ul.rc-menu-sub').at(0).hasClass('rc-menu-inline'), ).toBe(true); expect( - wrapper - .find('ul.rc-menu-sub') - .at(0) - .hasClass('rc-menu-hidden'), + wrapper.find('ul.rc-menu-sub').at(0).hasClass('rc-menu-hidden'), ).toBe(false); }); @@ -126,10 +115,14 @@ describe('Menu.Collapsed', () => { // Do collapsed wrapper.setProps({ inlineCollapsed: true }); - jest.runAllTimers(); - wrapper.update(); + act(() => { + jest.runAllTimers(); + wrapper.update(); + }); - wrapper.simulate('transitionEnd', { propertyName: 'width' }); + wrapper + .find('Overflow') + .simulate('transitionEnd', { propertyName: 'width' }); // Wait internal raf work act(() => { @@ -138,13 +131,12 @@ describe('Menu.Collapsed', () => { }); // Hover to show - wrapper - .find('.rc-menu-submenu-title') - .at(0) - .simulate('mouseEnter'); + wrapper.find('.rc-menu-submenu-title').at(0).simulate('mouseEnter'); - jest.runAllTimers(); - wrapper.update(); + act(() => { + jest.runAllTimers(); + wrapper.update(); + }); expect( wrapper @@ -153,22 +145,13 @@ describe('Menu.Collapsed', () => { .hasClass('rc-menu-submenu-vertical'), ).toBe(true); expect( - wrapper - .find('.rc-menu-submenu') - .at(0) - .hasClass('rc-menu-submenu-open'), + wrapper.find('.rc-menu-submenu').at(0).hasClass('rc-menu-submenu-open'), ).toBe(true); expect( - wrapper - .find('ul.rc-menu-sub') - .at(0) - .hasClass('rc-menu-vertical'), + wrapper.find('ul.rc-menu-sub').at(0).hasClass('rc-menu-vertical'), ).toBe(true); expect( - wrapper - .find('ul.rc-menu-sub') - .at(0) - .hasClass('rc-menu-hidden'), + wrapper.find('ul.rc-menu-sub').at(0).hasClass('rc-menu-hidden'), ).toBe(false); }); @@ -198,60 +181,12 @@ describe('Menu.Collapsed', () => { , ); - expect( - wrapper - .find(MenuItem) - .at(0) - .getDOMNode().title, - ).toBe(''); - expect( - wrapper - .find(MenuItem) - .at(1) - .getDOMNode().title, - ).toBe('title'); - expect( - wrapper - .find(MenuItem) - .at(2) - .getDOMNode().title, - ).toBe(''); - expect( - wrapper - .find(MenuItem) - .at(3) - .getDOMNode().title, - ).toBe(''); - expect( - wrapper - .find(MenuItem) - .at(4) - .getDOMNode().title, - ).toBe(''); - expect( - wrapper - .find(MenuItem) - .at(4) - .getDOMNode().title, - ).toBe(''); - }); - - it('should use siderCollapsed when siderCollapsed is passed', () => { - const wrapper = mount( - - - Option 1 - Option 2 - - menu2 - , - ); - expect(wrapper.instance().getInlineCollapsed()).toBe(false); + expect(wrapper.find(MenuItem).at(0).getDOMNode().title).toBe(''); + expect(wrapper.find(MenuItem).at(1).getDOMNode().title).toBe('title'); + expect(wrapper.find(MenuItem).at(2).getDOMNode().title).toBe(''); + expect(wrapper.find(MenuItem).at(3).getDOMNode().title).toBe(''); + expect(wrapper.find(MenuItem).at(4).getDOMNode().title).toBe(''); + expect(wrapper.find(MenuItem).at(4).getDOMNode().title).toBe(''); }); // https://github.com/ant-design/ant-design/issues/18825 @@ -262,7 +197,6 @@ describe('Menu.Collapsed', () => { mode="inline" inlineCollapsed={false} defaultSelectedKeys={['1']} - collapsedWidth={0} openKeys={['3']} > Option 1 @@ -272,38 +206,41 @@ describe('Menu.Collapsed', () => { , ); + + // Default expect( - wrapper.find('.rc-menu-item-selected').getDOMNode().textContent, + wrapper.find('li.rc-menu-item-selected').getDOMNode().textContent, ).toBe('Option 1'); - wrapper - .find('.rc-menu-item') - .at(1) - .simulate('click'); + + // Click to change select + wrapper.find('li.rc-menu-item').at(1).simulate('click'); expect( - wrapper.find('.rc-menu-item-selected').getDOMNode().textContent, + wrapper.find('li.rc-menu-item-selected').getDOMNode().textContent, ).toBe('Option 2'); + + // Collapse it wrapper.setProps({ inlineCollapsed: true }); - jest.runAllTimers(); - wrapper.update(); - expect( - wrapper - .find('Trigger') - .map(node => node.prop('popupVisible')) - .findIndex(node => !!node), - ).toBe(-1); + act(() => { + jest.runAllTimers(); + wrapper.update(); + }); + + // Open since controlled + expect(wrapper.find('Trigger').props().popupVisible).toBeTruthy(); + + // Expand it wrapper.setProps({ inlineCollapsed: false }); expect( - wrapper.find('.rc-menu-item-selected').getDOMNode().textContent, + wrapper.find('li.rc-menu-item-selected').getDOMNode().textContent, ).toBe('Option 2'); }); - it('should hideMenu in initial state when collapsed to 0px', () => { + it('should hideMenu in initial state when collapsed', () => { const wrapper = mount( Option 1 @@ -313,17 +250,16 @@ describe('Menu.Collapsed', () => { , ); - expect( - wrapper - .find('Trigger') - .map(node => node.prop('popupVisible')) - .findIndex(node => !!node), - ).toBe(-1); + + expect(wrapper.find('Trigger').props().popupVisible).toBeFalsy(); + wrapper.setProps({ inlineCollapsed: false }); - jest.runAllTimers(); - wrapper.update(); + act(() => { + jest.runAllTimers(); + wrapper.update(); + }); expect( - wrapper.find('.rc-menu-item-selected').getDOMNode().textContent, + wrapper.find('li.rc-menu-item-selected').getDOMNode().textContent, ).toBe('Option 1'); }); }); diff --git a/tests/Keyboard.spec.tsx b/tests/Keyboard.spec.tsx new file mode 100644 index 00000000..d7cd847f --- /dev/null +++ b/tests/Keyboard.spec.tsx @@ -0,0 +1,265 @@ +/* eslint-disable no-undef, react/no-multi-comp, react/jsx-curly-brace-presence, max-classes-per-file */ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { spyElementPrototypes } from 'rc-util/lib/test/domHook'; +import { mount } from './util'; +import type { ReactWrapper } from './util'; +import KeyCode from 'rc-util/lib/KeyCode'; +import Menu, { MenuItem, SubMenu } from '../src'; + +describe('Menu.Keyboard', () => { + let holder: HTMLDivElement; + + beforeAll(() => { + // Mock to force make menu item visible + spyElementPrototypes(HTMLElement, { + offsetParent: { + get() { + return this.parentElement; + }, + }, + }); + }); + + beforeEach(() => { + holder = document.createElement('div'); + document.body.appendChild(holder); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + holder.parentElement.removeChild(holder); + }); + + function keyDown(wrapper: ReactWrapper, keyCode: number) { + wrapper.find('ul.rc-menu-root').simulate('keyDown', { which: keyCode }); + + act(() => { + jest.runAllTimers(); + wrapper.update(); + }); + } + + it('keydown works when children change', async () => { + class App extends React.Component { + state = { + items: [1, 2, 3], + }; + + render() { + return ( + + {this.state.items.map(i => ( + {i} + ))} + + ); + } + } + + const wrapper = mount(, { attachTo: holder }); + + wrapper.setState({ items: [0, 1] }); + await wrapper.flush(); + + // First item + keyDown(wrapper, KeyCode.DOWN); + expect(wrapper.isActive(0)).toBeTruthy(); + + // Next item + keyDown(wrapper, KeyCode.DOWN); + expect(wrapper.isActive(1)).toBeTruthy(); + }); + + it('Skip disabled item', () => { + const wrapper = mount( + + 1 + + 2 + , + { attachTo: holder }, + ); + + // Next item + keyDown(wrapper, KeyCode.DOWN); + expect(wrapper.isActive(2)).toBeTruthy(); + + // Back to first item + keyDown(wrapper, KeyCode.UP); + expect(wrapper.isActive(0)).toBeTruthy(); + }); + + it('Enter to open menu and active first item', () => { + const wrapper = mount( + + + 1 + + , + { attachTo: holder }, + ); + + // Active first sub menu + keyDown(wrapper, KeyCode.DOWN); + + // Open it + keyDown(wrapper, KeyCode.ENTER); + expect(wrapper.find('PopupTrigger').prop('visible')).toBeTruthy(); + }); + + describe('go to children & back of parent', () => { + function testDirection( + direction: 'ltr' | 'rtl', + subKey: number, + parentKey: number, + ) { + it(`direction ${direction}`, () => { + const wrapper = mount( + + + + Little + + + , + { attachTo: holder }, + ); + + // Active first + keyDown(wrapper, KeyCode.DOWN); + + // Open and active sub + keyDown(wrapper, subKey); + expect( + wrapper.find('PopupTrigger').first().prop('visible'), + ).toBeTruthy(); + + expect( + wrapper + .find('.rc-menu-submenu-active .rc-menu-submenu-title') + .last() + .text(), + ).toEqual('Light'); + + // Open and active sub + keyDown(wrapper, subKey); + expect( + wrapper.find('PopupTrigger').last().prop('visible'), + ).toBeTruthy(); + expect(wrapper.find('.rc-menu-item-active').last().text()).toEqual( + 'Little', + ); + + // Back to parent + keyDown(wrapper, parentKey); + expect(wrapper.find('PopupTrigger').last().prop('visible')).toBeFalsy(); + expect(wrapper.find('.rc-menu-item-active')).toHaveLength(0); + + // Back to parent + keyDown(wrapper, parentKey); + + expect( + wrapper.find('PopupTrigger').first().prop('visible'), + ).toBeFalsy(); + + expect(wrapper.find('li.rc-menu-submenu-active')).toHaveLength(1); + + wrapper.unmount(); + }); + } + + testDirection('ltr', KeyCode.RIGHT, KeyCode.LEFT); + testDirection('rtl', KeyCode.LEFT, KeyCode.RIGHT); + }); + + it('inline keyboard', () => { + const wrapper = mount( + + Light + + Little + + , + { attachTo: holder }, + ); + + // Nothing happen when no control key + keyDown(wrapper, KeyCode.P); + expect(wrapper.exists('.rc-menu-item-active')).toBeFalsy(); + + // Active first + keyDown(wrapper, KeyCode.DOWN); + expect(wrapper.isActive(0)).toBeTruthy(); + + // Active next + keyDown(wrapper, KeyCode.DOWN); + + // Right will not open + keyDown(wrapper, KeyCode.RIGHT); + expect(wrapper.find('InlineSubMenuList').prop('open')).toBeFalsy(); + + // Trigger open + keyDown(wrapper, KeyCode.ENTER); + expect(wrapper.find('InlineSubMenuList').prop('open')).toBeTruthy(); + expect( + wrapper + .find('.rc-menu-submenu') + .last() + .hasClass('rc-menu-submenu-active'), + ).toBeTruthy(); + expect(wrapper.isActive(1)).toBeFalsy(); + + // Active sub item + keyDown(wrapper, KeyCode.DOWN); + expect(wrapper.isActive(1)).toBeTruthy(); + }); + + it('Focus last one', () => { + const wrapper = mount( + + Light + Bamboo + , + { attachTo: holder }, + ); + + keyDown(wrapper, KeyCode.UP); + expect(wrapper.isActive(1)).toBeTruthy(); + }); + + it('Focus to link direct', () => { + const wrapper = mount( + + + Light + + , + { attachTo: holder }, + ); + + const focusSpy = jest.spyOn( + (wrapper.find('a').instance() as any) as HTMLAnchorElement, + 'focus', + ); + + keyDown(wrapper, KeyCode.DOWN); + expect(focusSpy).toHaveBeenCalled(); + }); + + it('no dead loop', async () => { + const wrapper = mount( + + Little + , + { attachTo: holder }, + ); + + keyDown(wrapper, KeyCode.DOWN); + keyDown(wrapper, KeyCode.LEFT); + keyDown(wrapper, KeyCode.RIGHT); + expect(wrapper.isActive(0)).toBeTruthy(); + }); +}); +/* eslint-enable */ diff --git a/tests/Menu.spec.js b/tests/Menu.spec.js index 824686a9..d4312cb7 100644 --- a/tests/Menu.spec.js +++ b/tests/Menu.spec.js @@ -1,17 +1,21 @@ -/* eslint-disable no-undef, react/no-multi-comp, react/jsx-curly-brace-presence */ +/* eslint-disable no-undef, react/no-multi-comp, react/jsx-curly-brace-presence, max-classes-per-file */ import React from 'react'; -import { render, mount } from 'enzyme'; -import { renderToJson } from 'enzyme-to-json'; +import { act } from 'react-dom/test-utils'; +import { mount } from 'enzyme'; import KeyCode from 'rc-util/lib/KeyCode'; +import { resetWarned } from 'rc-util/lib/warning'; import Menu, { MenuItem, MenuItemGroup, SubMenu, Divider } from '../src'; -import * as mockedUtil from '../src/util'; -import { getMotion } from '../src/utils/legacyUtil'; describe('Menu', () => { describe('should render', () => { function createMenu(props) { return ( - + 1 @@ -33,21 +37,21 @@ describe('Menu', () => { ['vertical', 'horizontal', 'inline'].forEach(mode => { it(`${mode} menu correctly`, () => { - const wrapper = render(createMenu({ mode })); - expect(renderToJson(wrapper)).toMatchSnapshot(); + const wrapper = mount(createMenu({ mode })); + expect(wrapper.render()).toMatchSnapshot(); }); it(`${mode} menu with empty children without error`, () => { - expect(() => render({[]})).not.toThrow(); + expect(() => mount({[]})).not.toThrow(); }); it(`${mode} menu with undefined children without error`, () => { - expect(() => render()).not.toThrow(); + expect(() => mount()).not.toThrow(); }); it(`${mode} menu that has a submenu with undefined children without error`, () => { expect(() => - render( + mount( , @@ -57,14 +61,9 @@ describe('Menu', () => { it(`${mode} menu with rtl direction correctly`, () => { const wrapper = mount(createMenu({ mode, direction: 'rtl' })); - expect(renderToJson(render(wrapper))).toMatchSnapshot(); - - expect( - wrapper - .find('ul') - .first() - .props().className, - ).toContain('-rtl'); + expect(wrapper.render()).toMatchSnapshot(); + + expect(wrapper.find('ul').first().props().className).toContain('-rtl'); }); }); @@ -83,7 +82,7 @@ describe('Menu', () => { , ); - expect(render(wrapper)).toMatchSnapshot(); + expect(wrapper.render()).toMatchSnapshot(); }); }); @@ -105,8 +104,8 @@ describe('Menu', () => { } it('renders menu correctly', () => { - const wrapper = render(createMenu()); - expect(renderToJson(wrapper)).toMatchSnapshot(); + const wrapper = mount(createMenu()); + expect(wrapper.render()).toMatchSnapshot(); }); }); @@ -117,18 +116,12 @@ describe('Menu', () => { 2 , ); - expect( - wrapper - .find('MenuItem') - .first() - .props().active, - ).toBe(true); - expect( - wrapper - .find('MenuItem') - .last() - .props().active, - ).toBe(false); + expect(wrapper.isActive(0)).toBeTruthy(); + expect(wrapper.isActive(1)).toBeFalsy(); + + wrapper.setProps({ activeKey: '2' }); + expect(wrapper.isActive(0)).toBeFalsy(); + expect(wrapper.isActive(1)).toBeTruthy(); }); it('active first item', () => { @@ -139,11 +132,8 @@ describe('Menu', () => { , ); expect( - wrapper - .find('MenuItem') - .first() - .props().active, - ).toBe(true); + wrapper.find('.rc-menu-item').first().hasClass('rc-menu-item-active'), + ).toBeTruthy(); }); it('should render none menu item children', () => { @@ -171,16 +161,10 @@ describe('Menu', () => { 2 , ); - wrapper - .find('MenuItem') - .first() - .simulate('click'); - wrapper - .find('MenuItem') - .last() - .simulate('click'); - - expect(wrapper.find('.rc-menu-item-selected').length).toBe(2); + wrapper.find('.rc-menu-item').first().simulate('click'); + wrapper.find('.rc-menu-item').last().simulate('click'); + + expect(wrapper.find('li.rc-menu-item-selected')).toHaveLength(2); }); it('can be controlled by selectedKeys', () => { @@ -190,20 +174,38 @@ describe('Menu', () => { 2 , ); - expect( - wrapper - .find('li') - .first() - .props().className, - ).toContain('-selected'); + expect(wrapper.find('li').first().props().className).toContain('-selected'); wrapper.setProps({ selectedKeys: ['2'] }); wrapper.update(); - expect( - wrapper - .find('li') - .last() - .props().className, - ).toContain('-selected'); + expect(wrapper.find('li').last().props().className).toContain('-selected'); + }); + + it('empty selectedKeys not to throw', () => { + mount( + + foo + , + ); + }); + + it('not selectable', () => { + const onSelect = jest.fn(); + + const wrapper = mount( + + Bamboo + , + ); + + wrapper.findItem(0).simulate('click'); + expect(onSelect).toHaveBeenCalledWith( + expect.objectContaining({ selectedKeys: ['bamboo'] }), + ); + + onSelect.mockReset(); + wrapper.setProps({ selectable: false }); + wrapper.findItem(0).simulate('click'); + expect(onSelect).not.toHaveBeenCalled(); }); it('select default item', () => { @@ -213,12 +215,7 @@ describe('Menu', () => { 2 , ); - expect( - wrapper - .find('li') - .first() - .props().className, - ).toContain('-selected'); + expect(wrapper.find('li').first().props().className).toContain('-selected'); }); it('issue https://github.com/ant-design/ant-design/issues/29429', () => { @@ -230,44 +227,30 @@ describe('Menu', () => { 2 , ); - expect( - wrapper - .find('li') - .at(0) - .props().className, - ).not.toContain('-selected'); - expect( - wrapper - .find('li') - .at(1) - .props().className, - ).toContain('-selected'); + expect(wrapper.find('li').at(0).props().className).not.toContain( + '-selected', + ); + expect(wrapper.find('li').at(1).props().className).toContain('-selected'); }); it('can be controlled by openKeys', () => { const wrapper = mount( - + 1 - - + + 2 - + , ); - expect( - wrapper - .find('ul') - .first() - .props().className, - ).not.toContain('-hidden'); + + expect(wrapper.find('InlineSubMenuList').first().props().open).toBeTruthy(); + expect(wrapper.find('InlineSubMenuList').last().props().open).toBeFalsy(); + wrapper.setProps({ openKeys: ['g2'] }); - expect( - wrapper - .find('ul') - .last() - .props().className, - ).not.toContain('-hidden'); + expect(wrapper.find('InlineSubMenuList').first().props().open).toBeFalsy(); + expect(wrapper.find('InlineSubMenuList').last().props().open).toBeTruthy(); }); it('openKeys should allow to be empty', () => { @@ -292,22 +275,28 @@ describe('Menu', () => { }); it('open default submenu', () => { + jest.useFakeTimers(); + const wrapper = mount( - + 1 - - + + 2 - + , ); - expect( - wrapper - .find('ul') - .first() - .props().className, - ).not.toContain('-hidden'); + + act(() => { + jest.runAllTimers(); + wrapper.update(); + }); + + expect(wrapper.find('PopupTrigger').first().props().visible).toBeTruthy(); + expect(wrapper.find('PopupTrigger').last().props().visible).toBeFalsy(); + + jest.useRealTimers(); }); it('fires select event', () => { @@ -318,14 +307,15 @@ describe('Menu', () => { 2 , ); - wrapper - .find('MenuItem') - .first() - .simulate('click'); + wrapper.find('MenuItem').first().simulate('click'); expect(handleSelect.mock.calls[0][0].key).toBe('1'); }); it('fires click event', () => { + resetWarned(); + + const errorSpy = jest.spyOn(console, 'error'); + const handleClick = jest.fn(); const wrapper = mount( @@ -333,11 +323,16 @@ describe('Menu', () => { 2 , ); - wrapper - .find('MenuItem') - .first() - .simulate('click'); - expect(handleClick.mock.calls[0][0].key).toBe('1'); + wrapper.find('MenuItem').first().simulate('click'); + const info = handleClick.mock.calls[0][0]; + expect(info.key).toBe('1'); + expect(info.item).toBeTruthy(); + + expect(errorSpy).toHaveBeenCalledWith( + 'Warning: `info.item` is deprecated since we will move to function component that not provides React Node instance in future.', + ); + + errorSpy.mockRestore(); }); it('fires deselect event', () => { @@ -348,11 +343,7 @@ describe('Menu', () => { 2 , ); - wrapper - .find('MenuItem') - .first() - .simulate('click') - .simulate('click'); + wrapper.find('MenuItem').first().simulate('click').simulate('click'); expect(handleDeselect.mock.calls[0][0].key).toBe('1'); }); @@ -364,10 +355,8 @@ describe('Menu', () => { item2 , ); - let menuItem = wrapper.find('MenuItem').last(); - menuItem.simulate('mouseEnter'); - menuItem = wrapper.find('MenuItem').last(); - expect(menuItem.props().active).toBe(true); + wrapper.find('li').last().simulate('mouseEnter'); + expect(wrapper.isActive(2)).toBeTruthy(); }); it('active by key down', () => { @@ -378,81 +367,21 @@ describe('Menu', () => { , ); - wrapper.simulate('keyDown', { keyCode: KeyCode.DOWN }); - expect( - wrapper - .find('MenuItem') - .at(1) - .props().active, - ).toBe(true); - }); - - it('keydown works when children change', () => { - class App extends React.Component { - state = { - items: [1, 2, 3], - }; - - render() { - return ( - - {this.state.items.map(i => ( - {i} - ))} - - ); - } - } - - const wrapper = mount(); - - wrapper.setState({ items: [0, 1] }); + // KeyDown will not change activeKey since control + wrapper.find('Overflow').simulate('keyDown', { which: KeyCode.DOWN }); + expect(wrapper.isActive(0)).toBeTruthy(); - wrapper.find('Menu').simulate('keyDown', { keyCode: KeyCode.DOWN }); - expect( - wrapper - .find('MenuItem') - .at(0) - .props().active, - ).toBe(true); - - wrapper.find('Menu').simulate('keyDown', { keyCode: KeyCode.DOWN }); - expect( - wrapper - .find('MenuItem') - .at(1) - .props().active, - ).toBe(true); + wrapper.setProps({ activeKey: '2' }); + expect(wrapper.isActive(1)).toBeTruthy(); }); - it('active first item when children changes', () => { - class App extends React.Component { - state = { - items: ['foo'], - }; - - render() { - return ( - - {this.state.items.map(item => ( - {item} - ))} - - ); - } - } - - const wrapper = mount(); - - wrapper.setState({ items: ['bar', 'foo'] }); - wrapper.update(); - - expect( - wrapper - .find('li') - .first() - .hasClass('rc-menu-item-active'), - ).toBe(true); + it('defaultActiveFirst', () => { + const wrapper = mount( + + foo + , + ); + expect(wrapper.isActive(0)).toBeTruthy(); }); it('should accept builtinPlacements', () => { @@ -481,297 +410,71 @@ describe('Menu', () => { ); }); - describe('submenu mode', () => { - it('should use menu mode by default', () => { - const wrapper = mount( - - - menuItem - - , - ); - - expect( - wrapper - .find('SubMenu') - .first() - .prop('mode'), - ).toEqual('horizontal'); - }); + describe('motion', () => { + const defaultMotions = { + inline: { motionName: 'inlineMotion' }, + horizontal: { motionName: 'horizontalMotion' }, + other: { motionName: 'defaultMotion' }, + }; - it('should be able to customize SubMenu mode', () => { + it('defaultMotions should work correctly', () => { const wrapper = mount( - - - menuItem + + + , ); - expect( - wrapper - .find('SubMenu') - .first() - .prop('mode'), - ).toEqual('vertical-right'); - }); - }); - - describe('DOMWrap Allow Overflow', () => { - const overflowIndicatorSelector = 'SubMenu.rc-menu-overflowed-submenu'; - function createMenu(props) { - return ( - ...
    - } - {...props} - > - 1 - - 2 - - 3 - 4 - + // Inline + wrapper.setProps({ mode: 'inline' }); + expect(wrapper.find('CSSMotion').last().prop('motionName')).toEqual( + 'inlineMotion', ); - } - - let wrapper; - it('getWidth should contain margin when includeMargin is set to true', () => { - const memorizedGetComputedStyle = window.getComputedStyle; - window.getComputedStyle = () => ({ - marginLeft: '10px', - marginRight: '10px', - }); - expect( - mockedUtil.getWidth( - { - getBoundingClientRect() { - return { width: 10 }; - }, - }, - true, - ), - ).toEqual(30); - window.getComputedStyle = memorizedGetComputedStyle; - }); - - it('should not include overflow indicator when having enough width', () => { - const indicatorWidth = 50; // actual width including 40 px padding, which will be 50; - const liWidths = [50, 50, 50, 50]; - const availableWidth = 250; - const widths = [...liWidths, indicatorWidth, availableWidth]; - let i = 0; - mockedUtil.getWidth = () => { - const id = i; - i += 1; - return widths[id]; - }; - wrapper = mount(createMenu()); - - // overflow indicator placeholder - expect( - wrapper - .find(overflowIndicatorSelector) - .at(4) - .prop('style'), - ).toEqual({ - visibility: 'hidden', - position: 'absolute', - }); - - // last overflow indicator should be hidden - expect( - wrapper - .find(overflowIndicatorSelector) - .at(3) - .prop('style'), - ).toEqual({ - display: 'none', - }); - - expect( - wrapper - .find('MenuItem li') - .at(0) - .prop('style'), - ).toEqual({}); - expect( - wrapper - .find('MenuItem li') - .at(1) - .prop('style'), - ).toEqual({}); - expect( - wrapper - .find('MenuItem li') - .at(2) - .prop('style'), - ).toEqual({}); - expect( - wrapper - .find('MenuItem li') - .at(3) - .prop('style'), - ).toEqual({}); - }); - - it('should include overflow indicator when having not enough width', () => { - const indicatorWidth = 5; // actual width including 40 px padding, which will be 45; - const liWidths = [50, 50, 50, 50]; - const availableWidth = 145; - const widths = [...liWidths, indicatorWidth, availableWidth]; - let i = 0; - mockedUtil.getWidth = () => { - const id = i; - i += 1; - return widths[id]; - }; - wrapper = mount(createMenu()); - - expect( - wrapper - .find('MenuItem li') - .at(0) - .prop('style'), - ).toEqual({}); - expect( - wrapper - .find('MenuItem li') - .at(1) - .prop('style'), - ).toEqual({}); - expect( - wrapper - .find('MenuItem li') - .at(2) - .prop('style'), - ).toEqual({ display: 'none' }); + // Horizontal + wrapper.setProps({ mode: 'horizontal' }); expect( - wrapper - .find('MenuItem li') - .at(3) - .prop('style'), - ).toEqual({ display: 'none' }); + wrapper.find('Trigger').last().prop('popupMotion').motionName, + ).toEqual('horizontalMotion'); + // Default + wrapper.setProps({ mode: 'vertical' }); expect( - wrapper - .find(overflowIndicatorSelector) - .at(2) - .prop('style'), - ).toEqual({}); - expect( - wrapper - .find(overflowIndicatorSelector) - .at(3) - .prop('style'), - ).toEqual({ - display: 'none', - }); + wrapper.find('Trigger').last().prop('popupMotion').motionName, + ).toEqual('defaultMotion'); }); - describe('props changes', () => { - it('should recalculate overflow on children length changes', () => { - const liWidths = [50, 50, 50, 50]; - const availableWidth = 145; - const indicatorWidth = 45; - const widths = [...liWidths, indicatorWidth, availableWidth]; - let i = 0; - - mockedUtil.getWidth = () => { - const id = i; - i += 1; - return widths[id]; - }; - - wrapper = mount(createMenu()); - - expect(wrapper.find(overflowIndicatorSelector).length).toEqual(5); - expect( - wrapper - .find(overflowIndicatorSelector) - .at(1) - .prop('style'), - ).toEqual({ - display: 'none', - }); - expect( - wrapper - .find(overflowIndicatorSelector) - .at(2) - .prop('style'), - ).toEqual({}); - - wrapper.setProps({ children: child }); - wrapper.update(); - - expect(wrapper.find(overflowIndicatorSelector).length).toEqual(2); - expect( - wrapper - .find(overflowIndicatorSelector) - .at(0) - .prop('style'), - ).toEqual({ - display: 'none', - }); - }); - }); - }); - - describe('motion', () => { - it('defaultMotions should work correctly', () => { - const defaultMotions = { - inline: { motionName: 'inlineMotion' }, - horizontal: { motionName: 'horizontalMotion' }, - other: { motionName: 'defaultMotion' }, - }; + it('motion is first level', () => { const wrapper = mount( - , - ); - expect(getMotion(wrapper.props(), wrapper.state(), 'inline')).toEqual({ - motionName: 'inlineMotion', - }); - expect(getMotion(wrapper.props(), wrapper.state(), 'horizontal')).toEqual( - { - motionName: 'horizontalMotion', - }, + + + + + , ); - expect(getMotion(wrapper.props(), wrapper.state(), 'vertical')).toEqual({ - motionName: 'defaultMotion', - }); - }); - it('get correct animation type when switched from inline', () => { - const wrapper = mount(); - wrapper.setProps({ mode: 'horizontal' }); - expect(getMotion(wrapper.props(), wrapper.state())).toEqual(null); - }); - - it('warning if use `openAnimation` as object', () => { - const warnSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - mount(); - expect(warnSpy).toHaveBeenCalledWith( - 'Warning: Object type of `openAnimation` is removed. Please use `motion` instead.', + // Inline + wrapper.setProps({ mode: 'inline' }); + expect(wrapper.find('CSSMotion').last().prop('motionName')).toEqual( + 'bambooLight', ); - warnSpy.mockRestore(); - }); - it('motion object', () => { - const motion = { test: true }; - const wrapper = mount(); - expect(getMotion(wrapper.props(), wrapper.state())).toEqual(motion); - }); + // Horizontal + wrapper.setProps({ mode: 'horizontal' }); + expect( + wrapper.find('Trigger').last().prop('popupMotion').motionName, + ).toEqual('bambooLight'); - it('legacy openTransitionName', () => { - const wrapper = mount(); - expect(getMotion(wrapper.props(), wrapper.state())).toEqual({ - motionName: 'legacy', - }); + // Default + wrapper.setProps({ mode: 'vertical' }); + expect( + wrapper.find('Trigger').last().prop('popupMotion').motionName, + ).toEqual('bambooLight'); }); }); @@ -783,11 +486,21 @@ describe('Menu', () => { Navigation Two , ); - wrapper - .find(Menu) - .at(0) - .simulate('mouseenter'); + + wrapper.find('ul.rc-menu-root').simulate('mouseEnter'); expect(onMouseEnter).toHaveBeenCalled(); }); + + it('Nest children active should bump to top', async () => { + const wrapper = mount( + + + Light + + , + ); + + expect(wrapper.exists('.rc-menu-submenu-active')).toBeTruthy(); + }); }); /* eslint-enable */ diff --git a/tests/MenuItem.spec.js b/tests/MenuItem.spec.js index b08e4f49..21518529 100644 --- a/tests/MenuItem.spec.js +++ b/tests/MenuItem.spec.js @@ -1,11 +1,9 @@ /* eslint-disable no-undef */ import React from 'react'; -import { mount, shallow } from 'enzyme'; +import { mount } from 'enzyme'; import KeyCode from 'rc-util/lib/KeyCode'; import Menu, { MenuItem, MenuItemGroup, SubMenu } from '../src'; -import { MenuItem as NakedMenuItem } from '../src/MenuItem'; - describe('MenuItem', () => { const subMenuIconText = 'SubMenuIcon'; const menuItemIconText = 'MenuItemIcon'; @@ -24,10 +22,7 @@ describe('MenuItem', () => { 1 , ); - const menuItemText = wrapper - .find('.rc-menu-item') - .first() - .text(); + const menuItemText = wrapper.find('.rc-menu-item').first().text(); expect(menuItemText).toEqual(`1${menuItemIconText}`); }); @@ -41,94 +36,59 @@ describe('MenuItem', () => { 2 , ); - const menuItemText = wrapper - .find('.rc-menu-item') - .first() - .text(); + const menuItemText = wrapper.find('.rc-menu-item').first().text(); expect(menuItemText).toEqual(`1${targetText}`); }); }); - describe('disabled', () => { - it('can not be active by key down', () => { - const wrapper = mount( - - 1 - - 2 - , - ); - - wrapper.simulate('keyDown', { keyCode: KeyCode.DOWN }); - expect( - wrapper - .find('MenuItem') - .at(1) - .props().active, - ).toBe(false); - }); - - it('not fires select event when selected', () => { - const handleSelect = jest.fn(); - const wrapper = mount( - - - Item content - - , - ); - - wrapper.find('.xx').simulate('click'); - expect(handleSelect).not.toBeCalled(); - }); + it('not fires select event when disabled', () => { + const handleSelect = jest.fn(); + const wrapper = mount( + + + Item content + + , + ); + + wrapper.find('.xx').simulate('click'); + expect(handleSelect).not.toBeCalled(); }); describe('menuItem events', () => { - let onMouseEnter; - let onMouseLeave; - let onItemHover; - let wrapper; - let instance; - const domEvent = { keyCode: 13 }; - const key = '1'; - - beforeEach(() => { - onMouseEnter = jest.fn(); - onMouseLeave = jest.fn(); - onItemHover = jest.fn(); - - wrapper = shallow( - - 1 - , + function mountMenu(props, itemProps) { + return mount( + + + , + ); + } + + it('on enter key down should trigger onClick', () => { + const onItemClick = jest.fn(); + const wrapper = mountMenu(null, { onClick: onItemClick }); + wrapper.findItem().simulate('keyDown', { which: KeyCode.ENTER }); + expect(onItemClick).toHaveBeenCalledWith( + expect.objectContaining({ domEvent: expect.anything() }), ); - instance = wrapper.instance(); - }); - - it('on enter key down should trigger mouse click', () => { - instance.onClick = jest.fn(); - instance.onKeyDown(domEvent); - - expect(instance.onClick).toHaveBeenCalledWith(domEvent); }); - it('on mouse enter should trigger props.onItemHover props.onMouseEnter', () => { - instance.onMouseEnter(domEvent); - - expect(onItemHover).toHaveBeenCalledWith({ key, hover: true }); - expect(onMouseEnter).toHaveBeenCalledWith({ key, domEvent }); + it('on mouse enter should trigger onMouseEnter', () => { + const onItemMouseEnter = jest.fn(); + const wrapper = mountMenu(null, { onMouseEnter: onItemMouseEnter }); + wrapper.findItem().simulate('mouseEnter', { which: KeyCode.ENTER }); + expect(onItemMouseEnter).toHaveBeenCalledWith( + expect.objectContaining({ key: 'light', domEvent: expect.anything() }), + ); }); - it('on mouse leave should trigger props.onItemHover props.onMouseLeave', () => { - instance.onMouseLeave(domEvent); - - expect(onItemHover).toHaveBeenCalledWith({ key, hover: false }); - expect(onMouseLeave).toHaveBeenCalledWith({ key, domEvent }); + it('on mouse leave should trigger onMouseLeave', () => { + const onItemMouseLeave = jest.fn(); + const wrapper = mountMenu(null, { onMouseLeave: onItemMouseLeave }); + wrapper.findItem().simulate('mouseLeave', { which: KeyCode.ENTER }); + expect(onItemMouseLeave).toHaveBeenCalledWith( + expect.objectContaining({ key: 'light', domEvent: expect.anything() }), + ); }); }); @@ -142,12 +102,13 @@ describe('MenuItem', () => { className: 'className', style: { fontSize: 20 }, }; + const wrapper = mount( - + 1 - + 3 @@ -161,69 +122,57 @@ describe('MenuItem', () => { ); expect(wrapper.render()).toMatchSnapshot(); - wrapper - .find('MenuItem') - .at(0) - .simulate('click'); + + wrapper.findItem().simulate('click'); expect(onClick).toHaveBeenCalledTimes(1); - wrapper - .find('SubMenu') - .at(0) - .simulate('click'); + wrapper.find('.rc-menu-sub').simulate('click'); expect(onClick).toHaveBeenCalledTimes(1); - wrapper - .find('MenuItemGroup') - .at(0) - .simulate('click'); + wrapper.find('.rc-menu-item-group').simulate('click'); expect(onClick).toHaveBeenCalledTimes(1); }); }); describe('overwrite default role', () => { it('should set role to none if null', () => { - const wrapper = shallow(test); + const wrapper = mount( + + test + , + ); - expect(wrapper.render()).toMatchSnapshot(); + expect(wrapper.find('li').render()).toMatchSnapshot(); }); it('should set role to none if none', () => { - const wrapper = shallow(test); + const wrapper = mount( + + test + , + ); - expect(wrapper.render()).toMatchSnapshot(); + expect(wrapper.find('li').render()).toMatchSnapshot(); }); it('should set role to listitem', () => { - const wrapper = shallow( - test, + const wrapper = mount( + + test + , ); - expect(wrapper.render()).toMatchSnapshot(); + expect(wrapper.find('li').render()).toMatchSnapshot(); }); it('should set role to option', () => { - const wrapper = shallow( - test, - ); - - expect(wrapper.render()).toMatchSnapshot(); - }); - - it('should call onDestroy before unmount', () => { - const callback = jest.fn(); - const wrapper = mount( - - Item content - + test , ); - wrapper.unmount(); - - expect(callback).toHaveBeenCalled(); + expect(wrapper.find('li').render()).toMatchSnapshot(); }); }); }); diff --git a/tests/Responsive.spec.tsx b/tests/Responsive.spec.tsx new file mode 100644 index 00000000..86dbfde7 --- /dev/null +++ b/tests/Responsive.spec.tsx @@ -0,0 +1,82 @@ +/* eslint-disable no-undef, react/no-multi-comp, react/jsx-curly-brace-presence, max-classes-per-file */ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import KeyCode from 'rc-util/lib/KeyCode'; +import ResizeObserver from 'rc-resize-observer'; +import { mount } from './util'; +import Menu, { MenuItem, SubMenu } from '../src'; +import { OVERFLOW_KEY } from '../src/hooks/useKeyRecords'; + +describe('Menu.Responsive', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('show rest', () => { + const onOpenChange = jest.fn(); + const wrapper = mount( + + Light + Bamboo + + Little + + , + { attachTo: document.body }, + ); + + act(() => { + jest.runAllTimers(); + wrapper.update(); + }); + + // Set container width + act(() => { + wrapper + .find(ResizeObserver) + .first() + .props() + .onResize({} as any, { clientWidth: 41 } as any); + jest.runAllTimers(); + wrapper.update(); + }); + + // Resize every item + wrapper.find('Item').forEach(item => { + act(() => { + item + .find(ResizeObserver) + .props() + .onResize({ offsetWidth: 20 } as any, null); + jest.runAllTimers(); + wrapper.update(); + }); + }); + + // Should show the rest icon + expect( + wrapper.find('.rc-menu-overflow-item-rest').last().prop('style').opacity, + ).not.toEqual(0); + + // Should set active on rest + expect( + wrapper + .find('.rc-menu-overflow-item-rest') + .last() + .hasClass('rc-menu-submenu-active'), + ).toBeTruthy(); + + // Key down can open + expect(onOpenChange).not.toHaveBeenCalled(); + wrapper.setProps({ activeKey: OVERFLOW_KEY }); + wrapper + .find('ul.rc-menu-root') + .simulate('keyDown', { which: KeyCode.DOWN }); + expect(onOpenChange).toHaveBeenCalled(); + }); +}); +/* eslint-enable */ diff --git a/tests/SubMenu.spec.js b/tests/SubMenu.spec.js index 8e8930db..fbdf174c 100644 --- a/tests/SubMenu.spec.js +++ b/tests/SubMenu.spec.js @@ -1,15 +1,17 @@ /* eslint-disable no-undef */ import React from 'react'; import { mount } from 'enzyme'; -import KeyCode from 'rc-util/lib/KeyCode'; +import { act } from 'react-dom/test-utils'; import Menu, { MenuItem, SubMenu } from '../src'; describe('SubMenu', () => { - beforeAll(() => { + beforeEach(() => { jest.useFakeTimers(); }); - afterAll(() => {}); + afterEach(() => { + jest.useRealTimers(); + }); function createMenu(props) { return ( @@ -39,26 +41,21 @@ describe('SubMenu', () => { , ); - wrapper - .find('.rc-menu-submenu-title') - .first() - .simulate('mouseEnter'); - expect(wrapper.instance().store.getState().openKeys).toEqual([]); + wrapper.find('.rc-menu-submenu-title').first().simulate('mouseEnter'); + + expect(wrapper.find('PopupTrigger').prop('visible')).toBeFalsy(); }); it('offsets the submenu popover', () => { const wrapper = mount( - + 1 , ); - const popupAlign = wrapper - .find('Trigger') - .first() - .prop('popupAlign'); + const popupAlign = wrapper.find('Trigger').first().prop('popupAlign'); expect(popupAlign).toEqual({ offset: [0, 15] }); }); @@ -89,10 +86,7 @@ describe('SubMenu', () => { , ); - const subMenuText = wrapper - .find('.rc-menu-submenu-title') - .first() - .text(); + const subMenuText = wrapper.find('.rc-menu-submenu-title').first().text(); const subMenuTextWithExpandIconFunction = wrapperWithExpandIconFunction .find('.rc-menu-submenu-title') .first() @@ -103,7 +97,7 @@ describe('SubMenu', () => { it('should Not render custom arrow icon in horizontal mode.', () => { const wrapper = mount( - + { , ); - const childText = wrapper - .find('.rc-menu-submenu-title') - .at(1) - .text(); + const childText = wrapper.find('.rc-menu-submenu-title').hostNodes().text(); expect(childText).toEqual('submenu'); }); @@ -126,49 +117,51 @@ describe('SubMenu', () => { it('toggles when mouse enter and leave', () => { const wrapper = mount(createMenu()); - wrapper - .find('.rc-menu-submenu-title') - .first() - .simulate('mouseEnter'); - jest.runAllTimers(); - expect(wrapper.instance().store.getState().openKeys).toEqual(['s1']); + // Enter + wrapper.find('.rc-menu-submenu-title').first().simulate('mouseEnter'); + act(() => { + jest.runAllTimers(); + wrapper.update(); + }); + expect(wrapper.find('PopupTrigger').first().prop('visible')).toBeTruthy(); - wrapper - .find('.rc-menu-submenu-title') - .first() - .simulate('mouseLeave'); - jest.runAllTimers(); - expect(wrapper.instance().store.getState().openKeys).toEqual([]); + // Leave + wrapper.find('.rc-menu-submenu-title').first().simulate('mouseLeave'); + act(() => { + jest.runAllTimers(); + wrapper.update(); + }); + expect(wrapper.find('PopupTrigger').first().prop('visible')).toBeFalsy(); }); }); describe('openSubMenuOnMouseEnter and closeSubMenuOnMouseLeave are false', () => { - let wrapper; - - beforeEach(() => { - wrapper = mount( + it('toggles when mouse click', () => { + const wrapper = mount( createMenu({ triggerSubMenuAction: 'click', }), ); - }); - it('toggles when mouse click', () => { - wrapper - .find('.rc-menu-submenu-title') - .first() - .simulate('click'); - expect(wrapper.instance().store.getState().openKeys).toEqual(['s1']); - - wrapper - .find('.rc-menu-submenu-title') - .first() - .simulate('click'); - expect(wrapper.instance().store.getState().openKeys).toEqual([]); + wrapper.find('.rc-menu-submenu-title').first().simulate('click'); + act(() => { + jest.runAllTimers(); + wrapper.update(); + }); + expect(wrapper.find('PopupTrigger').first().prop('visible')).toBeTruthy(); + + wrapper.find('.rc-menu-submenu-title').first().simulate('click'); + act(() => { + jest.runAllTimers(); + wrapper.update(); + }); + expect(wrapper.find('PopupTrigger').first().prop('visible')).toBeFalsy(); }); }); it('fires openChange event', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const handleOpenChange = jest.fn(); const wrapper = mount( @@ -182,69 +175,59 @@ describe('SubMenu', () => { , ); - wrapper - .find('.rc-menu-submenu-title') - .at(0) - .simulate('mouseEnter'); - jest.runAllTimers(); - expect(handleOpenChange).toHaveBeenCalledWith(['item_1']); - - wrapper.update(); + // First + wrapper.find('div.rc-menu-submenu-title').at(0).simulate('mouseEnter'); + act(() => { + jest.runAllTimers(); + wrapper.update(); + }); + expect(handleOpenChange).toHaveBeenCalledWith(['tmp_key-1']); - wrapper - .find('.rc-menu-submenu-title') - .at(1) - .simulate('mouseEnter'); - jest.runAllTimers(); + // Second + wrapper.find('div.rc-menu-submenu-title').at(1).simulate('mouseEnter'); + act(() => { + jest.runAllTimers(); + wrapper.update(); + }); expect(handleOpenChange).toHaveBeenCalledWith([ - 'item_1', - 'item_1-menu-item_1', + 'tmp_key-1', + 'tmp_key-tmp_key-1-1', ]); + + expect(errorSpy).toHaveBeenCalledWith( + 'Warning: MenuItem or SubMenu should not leave undefined `key`.', + ); + + errorSpy.mockRestore(); }); describe('mouse events', () => { it('mouse enter event on a submenu should not activate first item', () => { const wrapper = mount(createMenu({ openKeys: ['s1'] })); - const title = wrapper.find('.rc-menu-submenu-title').first(); + const title = wrapper.find('div.rc-menu-submenu-title').first(); title.simulate('mouseEnter'); - jest.runAllTimers(); - wrapper.update(); + act(() => { + jest.runAllTimers(); + wrapper.update(); + }); - expect( - wrapper - .find('.rc-menu-sub') - .first() - .is('.rc-menu-hidden'), - ).toBe(false); - expect( - wrapper - .find('MenuItem') - .first() - .props().active, - ).toBe(false); + expect(wrapper.find('PopupTrigger').first().prop('visible')).toBeTruthy(); + expect(wrapper.isActive(0)).toBeFalsy(); }); it('click to open a submenu should not activate first item', () => { const wrapper = mount(createMenu({ triggerSubMenuAction: 'click' })); - const subMenuTitle = wrapper.find('.rc-menu-submenu-title').first(); + const subMenuTitle = wrapper.find('div.rc-menu-submenu-title').first(); subMenuTitle.simulate('click'); - jest.runAllTimers(); - wrapper.update(); + act(() => { + jest.runAllTimers(); + wrapper.update(); + }); - expect( - wrapper - .find('.rc-menu-sub') - .first() - .is('.rc-menu-hidden'), - ).toBe(false); - expect( - wrapper - .find('MenuItem') - .first() - .props().active, - ).toBe(false); + expect(wrapper.find('PopupTrigger').first().prop('visible')).toBeTruthy(); + expect(wrapper.isActive(0)).toBeFalsy(); }); it('mouse enter/mouse leave on a subMenu item should trigger hooks', () => { @@ -263,7 +246,6 @@ describe('SubMenu', () => { , ); const subMenu = wrapper.find('.rc-menu-submenu').first(); - subMenu.simulate('mouseEnter'); expect(onMouseEnter).toHaveBeenCalledTimes(1); @@ -272,141 +254,32 @@ describe('SubMenu', () => { }); }); - describe('key press', () => { - describe('enter key', () => { - it('opens menu and active first item', () => { - const wrapper = mount(createMenu()); - const title = wrapper.find('.rc-menu-submenu-title').first(); - - title - .simulate('mouseEnter') - .simulate('keyDown', { keyCode: KeyCode.ENTER }); - - jest.runAllTimers(); - wrapper.update(); - - expect( - wrapper - .find('.rc-menu-sub') - .first() - .is('.rc-menu-hidden'), - ).toBe(false); - expect( - wrapper - .find('MenuItem') - .first() - .props().active, - ).toBe(true); - }); - }); - - describe('left & right key', () => { - it('toggles menu', () => { - const wrapper = mount(createMenu({ defaultActiveFirst: true })); - const title = wrapper.find('.rc-menu-submenu-title').first(); - - title - .simulate('mouseEnter') - .simulate('keyDown', { keyCode: KeyCode.LEFT }); - expect(wrapper.instance().store.getState().openKeys).toEqual([]); - title.simulate('keyDown', { keyCode: KeyCode.RIGHT }); - expect(wrapper.instance().store.getState().openKeys).toEqual(['s1']); - expect( - wrapper - .find('MenuItem') - .first() - .props().active, - ).toBe(true); - }); - }); - - it('up & down key', () => { - const wrapper = mount(createMenu()); - const titles = wrapper.find('.rc-menu-submenu-title'); - - titles - .first() - .simulate('mouseEnter') - .simulate('keyDown', { keyCode: KeyCode.LEFT }) - .simulate('keyDown', { keyCode: KeyCode.DOWN }); - expect( - wrapper - .find('.rc-menu-submenu') - .last() - .is('.rc-menu-submenu-active'), - ).toBe(true); - - titles.last().simulate('keyDown', { keyCode: KeyCode.UP }); - expect( - wrapper - .find('.rc-menu-submenu') - .first() - .is('.rc-menu-submenu-active'), - ).toBe(true); - }); - - it('combined key presses', () => { - const wrapper = mount(createMenu()); - const titles = wrapper.find('.rc-menu-submenu-title'); - const firstItem = titles.first(); - - // testing keydown event after submenu is closed and then opened again - firstItem - .simulate('mouseEnter') - .simulate('keyDown', { keyCode: KeyCode.RIGHT }) - .simulate('keyDown', { keyCode: KeyCode.LEFT }) - .simulate('keyDown', { keyCode: KeyCode.RIGHT }) - .simulate('keyDown', { keyCode: KeyCode.DOWN }) - .simulate('keyDown', { keyCode: KeyCode.DOWN }) - .simulate('keyDown', { keyCode: KeyCode.DOWN }); - - expect( - wrapper - .find('[title="submenu1-1"]') - .find('.rc-menu-submenu') - .first() - .is('.rc-menu-submenu-active'), - ).toBe(true); - }); - }); - it('fires select event', () => { const handleSelect = jest.fn(); const wrapper = mount(createMenu({ onSelect: handleSelect })); - wrapper - .find('.rc-menu-submenu-title') - .first() - .simulate('mouseEnter'); + wrapper.find('.rc-menu-submenu-title').first().simulate('mouseEnter'); - jest.runAllTimers(); - wrapper.update(); + act(() => { + jest.runAllTimers(); + wrapper.update(); + }); - wrapper - .find('MenuItem') - .first() - .simulate('click'); + wrapper.findItem().simulate('click'); expect(handleSelect.mock.calls[0][0].key).toBe('s1-1'); }); it('fires select event with className', () => { const wrapper = mount(createMenu()); - wrapper - .find('.rc-menu-submenu-title') - .first() - .simulate('mouseEnter'); + wrapper.find('.rc-menu-submenu-title').first().simulate('mouseEnter'); - jest.runAllTimers(); - wrapper.update(); + act(() => { + jest.runAllTimers(); + wrapper.update(); + }); - wrapper - .find('MenuItem') - .first() - .simulate('click'); + wrapper.find('MenuItem').first().simulate('click'); expect( - wrapper - .find('.rc-menu-submenu') - .first() - .is('.rc-menu-submenu-selected'), + wrapper.find('.rc-menu-submenu').first().is('.rc-menu-submenu-selected'), ).toBe(true); }); @@ -418,144 +291,68 @@ describe('SubMenu', () => { onDeselect: handleDeselect, }), ); - wrapper - .find('.rc-menu-submenu-title') - .first() - .simulate('mouseEnter'); - - jest.runAllTimers(); - wrapper.update(); + wrapper.find('.rc-menu-submenu-title').first().simulate('mouseEnter'); - wrapper - .find('MenuItem') - .first() - .simulate('click'); - wrapper - .find('MenuItem') - .first() - .simulate('click'); + act(() => { + jest.runAllTimers(); + wrapper.update(); + }); + wrapper.findItem().simulate('click'); + wrapper.findItem().simulate('click'); expect(handleDeselect.mock.calls[0][0].key).toBe('s1-1'); }); - describe('horizontal menu', () => { - it('should automatically adjust width', () => { - const props = { - mode: 'horizontal', - openKeys: ['s1'], - }; - - const wrapper = mount( - - 1 - - 2 - - , - ); - - // every item has a prefixed overflow indicator as a submenu - // so we have to get the 3rd submenu - const subMenuInstance = wrapper - .find('SubMenu') - .at(2) - .instance(); - const adjustWidthSpy = jest.spyOn(subMenuInstance, 'adjustWidth'); - - jest.runAllTimers(); + it('should take style prop', () => { + const App = () => ( + + + 1 + + + ); - expect(adjustWidthSpy).toHaveBeenCalledTimes(1); + const wrapper = mount(); + expect(wrapper.find('Menu ul').prop('style')).toEqual({ + backgroundColor: 'black', }); }); - describe('submenu animation', () => { - const appear = () => {}; - - it('should animate with transition class', () => { - const wrapper = mount( - createMenu({ - openTransitionName: 'fade', - mode: 'inline', - }), - ); - - const title = wrapper.find('.rc-menu-submenu-title').first(); - - title.simulate('click'); - jest.runAllTimers(); - expect(wrapper.find('CSSMotion').prop('motionName')).toEqual('fade'); - }); - - it('should not animate on initially opened menu', () => { - const wrapper = mount( - createMenu({ - openAnimation: { appear }, - mode: 'inline', - openKeys: ['s1'], - }), - ); - - expect( - wrapper - .find('CSSMotion') - .first() - .prop('motionAppear'), - ).toBeFalsy(); - }); - - it('should animate with config', () => { - const wrapper = mount( - createMenu({ - openAnimation: { appear }, - mode: 'inline', - }), - ); - - const title = wrapper.find('.rc-menu-submenu-title').first(); - - title.simulate('click'); - jest.runAllTimers(); + it('not pass style into sub menu item', () => { + const wrapper = mount( + + + 1 + + , + ); - expect(wrapper.find('CSSMotion').prop('motionAppear')).toBeTruthy(); + expect(wrapper.find('li.rc-menu-item').at(0).props().style).toEqual({ + color: 'red', }); }); - describe('.componentWillUnmount()', () => { - it('should invoke hooks', () => { - const onDestroy = jest.fn(); - const App = props => ( - - {props.show && ( - - 1 - - )} - - ); - - const wrapper = mount(); + it('inline click for open', () => { + const onOpenChange = jest.fn(); - wrapper.setProps({ show: false }); - - expect(onDestroy).toHaveBeenCalledWith('s1'); - }); - }); + const wrapper = mount( + + + Little + + + Sub + + , + ); - describe('customizing style', () => { - it('should take style prop', () => { - const App = () => ( - - - 1 - - - ); + // Disabled + wrapper.find('div.rc-menu-submenu-title').first().simulate('click'); + expect(onOpenChange).not.toHaveBeenCalled(); - const wrapper = mount(); - expect(wrapper.find('Menu ul').prop('style')).toEqual({ - backgroundColor: 'black', - }); - }); + // Disabled + wrapper.find('div.rc-menu-submenu-title').last().simulate('click'); + expect(onOpenChange).toHaveBeenCalledWith(['light']); }); }); /* eslint-enable */ diff --git a/tests/SubPopupMenu.spec.js b/tests/SubPopupMenu.spec.js deleted file mode 100644 index cfbfaed6..00000000 --- a/tests/SubPopupMenu.spec.js +++ /dev/null @@ -1,73 +0,0 @@ -import React from 'react'; -import { mount } from 'enzyme'; -import { saveRef } from '../src/SubPopupMenu'; -import Menu, { MenuItem } from '../src'; - -describe('SubPopupMenu', () => { - let subPopupMenu; - - beforeEach(() => { - subPopupMenu = { instanceArray: [] }; - }); - - it('saveRef should add new component ref', () => { - saveRef.call(subPopupMenu, { props: { eventKey: '1' } }); - - expect(subPopupMenu.instanceArray).toEqual([{ props: { eventKey: '1' } }]); - }); - - it('saveRef should update component ref', () => { - const c = { props: { eventKey: '1', name: 'old' } }; - subPopupMenu.instanceArray[0] = c; - saveRef.call(subPopupMenu, c); - - expect(subPopupMenu.instanceArray).toEqual([c]); - }); - - it('activeKey change should reset store activeKey', () => { - const wrapper = mount( - - 1 - 2 - , - ); - - function getItemActive(index) { - return wrapper - .find('MenuItem') - .at(index) - .instance().props.active; - } - expect(getItemActive(0)).toBe(true); - expect(getItemActive(1)).toBe(false); - - wrapper.setProps({ activeKey: '2' }); - - expect(getItemActive(0)).toBe(false); - expect(getItemActive(1)).toBe(true); - }); - - it('not pass style into sub menu item', () => { - const wrapper = mount( - - - 1 - - , - ); - - expect( - wrapper - .find('li.rc-menu-overflowed-submenu') - .at(0) - .props().style, - ).toEqual({ color: 'red', display: 'none' }); - - expect( - wrapper - .find('li.rc-menu-item') - .at(0) - .props().style, - ).toEqual({ color: 'red' }); - }); -}); diff --git a/tests/__snapshots__/Menu.spec.js.snap b/tests/__snapshots__/Menu.spec.js.snap index c1a67aeb..0a93bb6c 100644 --- a/tests/__snapshots__/Menu.spec.js.snap +++ b/tests/__snapshots__/Menu.spec.js.snap @@ -2,28 +2,35 @@ exports[`Menu render role listbox renders menu correctly 1`] = `
    • 1
    • 2
    • 3
    • @@ -32,31 +39,13 @@ exports[`Menu render role listbox renders menu correctly 1`] = ` exports[`Menu should render horizontal menu correctly 1`] = ` - -
    - - `; exports[`Menu should render horizontal menu with rtl direction correctly 1`] = ` - -
  • - - `; exports[`Menu should render inline menu correctly 1`] = ` `; exports[`Menu should render inline menu with rtl direction correctly 1`] = ` `; exports[`Menu should render should support Fragment 1`] = `