diff --git a/package.json b/package.json index 30148908..407f9fd4 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "classnames": "2.x", "create-react-class": "^15.5.2", "dom-scroll-into-view": "1.x", + "mini-store": "^1.0.2", "prop-types": "^15.5.6", "rc-animate": "2.x", "rc-trigger": "^2.3.0", diff --git a/src/Menu.jsx b/src/Menu.jsx index 771c3cd7..d9ff7750 100644 --- a/src/Menu.jsx +++ b/src/Menu.jsx @@ -1,7 +1,8 @@ -// import React from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; -import MenuMixin from './MenuMixin'; +import { Provider, create } from 'mini-store'; +import { default as MenuMixin, getActiveKey } from './MenuMixin'; import { noop } from './util'; const Menu = createReactClass({ @@ -59,20 +60,24 @@ const Menu = createReactClass({ if ('openKeys' in props) { openKeys = props.openKeys || []; } - return { + + this.store = create({ selectedKeys, openKeys, - }; + activeKey: { '0-menu-': getActiveKey(props, props.activeKey) }, + }); + + return {}; }, componentWillReceiveProps(nextProps) { if ('selectedKeys' in nextProps) { - this.setState({ + this.store.setState({ selectedKeys: nextProps.selectedKeys || [], }); } if ('openKeys' in nextProps) { - this.setState({ + this.store.setState({ openKeys: nextProps.openKeys || [], }); } @@ -82,7 +87,7 @@ const Menu = createReactClass({ const props = this.props; if (props.selectable) { // root menu - let selectedKeys = this.state.selectedKeys; + let selectedKeys = this.store.getState().selectedKeys; const selectedKey = selectInfo.key; if (props.multiple) { selectedKeys = selectedKeys.concat([selectedKey]); @@ -90,7 +95,7 @@ const Menu = createReactClass({ selectedKeys = [selectedKey]; } if (!('selectedKeys' in props)) { - this.setState({ + this.store.setState({ selectedKeys, }); } @@ -107,7 +112,7 @@ const Menu = createReactClass({ onOpenChange(event) { const props = this.props; - const openKeys = this.state.openKeys.concat(); + const openKeys = this.store.getState().openKeys.concat(); let changed = false; const processSingle = (e) => { let oneChanged = false; @@ -133,7 +138,7 @@ const Menu = createReactClass({ } if (changed) { if (!('openKeys' in this.props)) { - this.setState({ openKeys }); + this.store.setState({ openKeys }); } props.onOpenChange(openKeys); } @@ -142,14 +147,14 @@ const Menu = createReactClass({ onDeselect(selectInfo) { const props = this.props; if (props.selectable) { - const selectedKeys = this.state.selectedKeys.concat(); + const selectedKeys = this.store.getState().selectedKeys.concat(); const selectedKey = selectInfo.key; const index = selectedKeys.indexOf(selectedKey); if (index !== -1) { selectedKeys.splice(index, 1); } if (!('selectedKeys' in props)) { - this.setState({ + this.store.setState({ selectedKeys, }); } @@ -176,7 +181,7 @@ const Menu = createReactClass({ lastOpenSubMenu() { let lastOpen = []; - const { openKeys } = this.state; + const { openKeys } = this.store.getState(); if (openKeys.length) { lastOpen = this.getFlatInstanceArray().filter((c) => { return c && openKeys.indexOf(c.props.eventKey) !== -1; @@ -185,15 +190,16 @@ const Menu = createReactClass({ return lastOpen[0]; }, - renderMenuItem(c, i, subIndex) { + renderMenuItem(c, i, subIndex, subMenuKey) { if (!c) { return null; } - const state = this.state; + const state = this.store.getState(); const extraProps = { openKeys: state.openKeys, selectedKeys: state.selectedKeys, triggerSubMenuAction: this.props.triggerSubMenuAction, + subMenuKey, }; return this.renderCommonMenuItem(c, i, subIndex, extraProps); }, @@ -201,7 +207,11 @@ const Menu = createReactClass({ render() { const props = { ...this.props }; props.className += ` ${props.prefixCls}-root`; - return this.renderRoot(props); + return ( + + {this.renderRoot(props)} + + ); }, }); diff --git a/src/MenuItem.jsx b/src/MenuItem.jsx index d8d64af1..bd7bedfe 100644 --- a/src/MenuItem.jsx +++ b/src/MenuItem.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; import KeyCode from 'rc-util/lib/KeyCode'; import classNames from 'classnames'; +import { connect } from 'mini-store'; import { noop } from './util'; /* eslint react/no-is-mounted:0 */ @@ -43,6 +44,13 @@ const MenuItem = createReactClass({ } }, + componentDidMount() { + // invoke customized ref to expose component to mixin + if (this.props.manualRef) { + this.props.manualRef(this); + } + }, + onKeyDown(e) { const keyCode = e.keyCode; if (keyCode === KeyCode.ENTER) { @@ -76,8 +84,7 @@ const MenuItem = createReactClass({ }, onClick(e) { - const { eventKey, multiple, onClick, onSelect, onDeselect } = this.props; - const selected = this.isSelected(); + const { eventKey, multiple, onClick, onSelect, onDeselect, isSelected } = this.props; const info = { key: eventKey, keyPath: [eventKey], @@ -86,12 +93,12 @@ const MenuItem = createReactClass({ }; onClick(info); if (multiple) { - if (selected) { + if (isSelected) { onDeselect(info); } else { onSelect(info); } - } else if (!selected) { + } else if (!isSelected) { onSelect(info); } }, @@ -112,16 +119,11 @@ const MenuItem = createReactClass({ return `${this.getPrefixCls()}-disabled`; }, - isSelected() { - return this.props.selectedKeys.indexOf(this.props.eventKey) !== -1; - }, - render() { const props = this.props; - const selected = this.isSelected(); const className = classNames(this.getPrefixCls(), props.className, { [this.getActiveClassName()]: !props.disabled && props.active, - [this.getSelectedClassName()]: selected, + [this.getSelectedClassName()]: props.isSelected, [this.getDisabledClassName()]: props.disabled, }); const attrs = { @@ -129,7 +131,7 @@ const MenuItem = createReactClass({ title: props.title, className, role: 'menuitem', - 'aria-selected': selected, + 'aria-selected': props.isSelected, 'aria-disabled': props.disabled, }; let mouseEvent = {}; @@ -160,4 +162,7 @@ const MenuItem = createReactClass({ MenuItem.isMenuItem = 1; -export default MenuItem; +export default connect(({ activeKey, selectedKeys }, { eventKey, subMenuKey }) => ({ + active: activeKey[subMenuKey] === eventKey, + isSelected: selectedKeys.indexOf(eventKey) !== -1, +}))(MenuItem); diff --git a/src/MenuItemGroup.jsx b/src/MenuItemGroup.jsx index 813eb7f1..c6f189c9 100644 --- a/src/MenuItemGroup.jsx +++ b/src/MenuItemGroup.jsx @@ -19,7 +19,7 @@ const MenuItemGroup = createReactClass({ renderInnerMenuItem(item, subIndex) { const { renderMenuItem, index } = this.props; - return renderMenuItem(item, index, subIndex); + return renderMenuItem(item, index, subIndex, this.props.subMenuKey); }, render() { diff --git a/src/MenuMixin.js b/src/MenuMixin.js index f1515871..a960afc4 100644 --- a/src/MenuMixin.js +++ b/src/MenuMixin.js @@ -15,7 +15,17 @@ function allDisabled(arr) { return arr.every(c => !!c.props.disabled); } -function getActiveKey(props, originalActiveKey) { +function updateActiveKey(store, menuId, activeKey) { + const state = store.getState(); + store.setState({ + activeKey: { + ...state.activeKey, + [menuId]: activeKey, + }, + }); +} + +export function getActiveKey(props, originalActiveKey) { let activeKey = originalActiveKey; const { children, eventKey } = props; if (activeKey) { @@ -80,31 +90,13 @@ const MenuMixin = { }; }, - getInitialState() { - const props = this.props; - return { - activeKey: getActiveKey(props, props.activeKey), - }; - }, - componentWillReceiveProps(nextProps) { - let props; - if ('activeKey' in nextProps) { - props = { - activeKey: getActiveKey(nextProps, nextProps.activeKey), - }; - } else { - const originalActiveKey = this.state.activeKey; - const activeKey = getActiveKey(nextProps, originalActiveKey); - // fix: this.setState(), parent.render(), - if (activeKey !== originalActiveKey) { - props = { - activeKey, - }; - } - } - if (props) { - this.setState(props); + let activeKey; + const originalActiveKey = this.getStore().getState().activeKey[this.getEventKey()]; + activeKey = getActiveKey(nextProps, originalActiveKey); + // fix: this.setState(), parent.render(), + if (activeKey !== originalActiveKey) { + updateActiveKey(this.getStore(), this.getEventKey(), activeKey); } }, @@ -112,6 +104,15 @@ const MenuMixin = { return this.props.visible || nextProps.visible; }, + componentDidUpdate() { + if (this.activeItem) { + scrollIntoView(ReactDOM.findDOMNode(this.activeItem), ReactDOM.findDOMNode(this), { + onlyScrollIfNeeded: true, + }); + this.activeItem = undefined; + } + }, + componentWillMount() { this.instanceArray = []; }, @@ -134,32 +135,35 @@ const MenuMixin = { } if (activeItem) { e.preventDefault(); - this.setState({ - activeKey: activeItem.props.eventKey, - }, () => { - scrollIntoView(ReactDOM.findDOMNode(activeItem), ReactDOM.findDOMNode(this), { - onlyScrollIfNeeded: true, - }); - // https://github.com/react-component/menu/commit/9899a9672f6f028ec3cdf773f1ecea5badd2d33e - if (typeof callback === 'function') { - callback(activeItem); - } - }); + updateActiveKey(this.getStore(), this.getEventKey(), activeItem.props.eventKey); + + this.activeItem = activeItem; + if (typeof callback === 'function') { + callback(activeItem); + } + return 1; } else if (activeItem === undefined) { e.preventDefault(); - this.setState({ - activeKey: null, - }); + updateActiveKey(this.getStore(), this.getEventKey(), null); return 1; } }, onItemHover(e) { const { key, hover } = e; - this.setState({ - activeKey: hover ? key : null, - }); + updateActiveKey(this.getStore(), this.getEventKey(), hover ? key : null); + }, + + getEventKey() { + // when eventKey not available ,it's menu and return menu id '0-menu-' + return this.props.eventKey || '0-menu-'; + }, + + getStore() { + const store = this.store || this.props.store; + + return store; }, getFlatInstanceArray() { @@ -182,7 +186,7 @@ const MenuMixin = { }, renderCommonMenuItem(child, i, subIndex, extraProps) { - const state = this.state; + const state = this.getStore().getState(); const props = this.props; const key = getKeyFromChildrenIndex(child, props.eventKey, i); const childProps = child.props; @@ -195,7 +199,8 @@ const MenuMixin = { rootPrefixCls: props.prefixCls, index: i, parentMenu: this, - ref: childProps.disabled ? undefined : + // customized ref function, need to be invoked manually in child's componentDidMount + manualRef: childProps.disabled ? undefined : createChainedFunction(child.ref, saveRef.bind(this, i, subIndex)), eventKey: key, active: !childProps.disabled && isActive, @@ -219,7 +224,6 @@ const MenuMixin = { }, renderRoot(props) { - this.instanceArray = []; const className = classNames( props.prefixCls, props.className, @@ -247,7 +251,10 @@ const MenuMixin = { visible={props.visible} {...domProps} > - {React.Children.map(props.children, this.renderMenuItem)} + {React.Children.map( + props.children, + (c, i, subIndex) => this.renderMenuItem(c, i, subIndex, props.eventKey || '0-menu-'), + )} /*eslint-enable */ ); @@ -255,7 +262,7 @@ const MenuMixin = { step(direction) { let children = this.getFlatInstanceArray(); - const activeKey = this.state.activeKey; + const activeKey = this.getStore().getState().activeKey[this.getEventKey()]; const len = children.length; if (!len) { return null; diff --git a/src/SubMenu.jsx b/src/SubMenu.jsx index f6aa122d..a2b34009 100644 --- a/src/SubMenu.jsx +++ b/src/SubMenu.jsx @@ -5,9 +5,14 @@ import createReactClass from 'create-react-class'; import Trigger from 'rc-trigger'; import KeyCode from 'rc-util/lib/KeyCode'; import classNames from 'classnames'; +import { connect } from 'mini-store'; import SubPopupMenu from './SubPopupMenu'; import placements from './placements'; -import { noop, loopMenuItemRecusively } from './util'; +import { + noop, + loopMenuItemRecusively, + getMenuIdFromSubMenuEventKey, +} from './util'; let guid = 0; @@ -18,6 +23,17 @@ const popupPlacementMap = { 'vertical-right': 'leftTop', }; +const updateDefaultActiveFirst = (store, eventKey, defaultActiveFirst) => { + const menuId = getMenuIdFromSubMenuEventKey(eventKey); + const state = store.getState(); + store.setState({ + defaultActiveFirst: { + ...state.defaultActiveFirst, + [menuId]: defaultActiveFirst, + }, + }); +}; + const SubMenu = createReactClass({ displayName: 'SubMenu', @@ -43,6 +59,7 @@ const SubMenu = createReactClass({ onTitleMouseEnter: PropTypes.func, onTitleMouseLeave: PropTypes.func, onTitleClick: PropTypes.func, + isOpen: PropTypes.bool, }, isRootMenu: false, @@ -60,18 +77,32 @@ const SubMenu = createReactClass({ getInitialState() { this.isSubMenu = 1; - return { - defaultActiveFirst: false, - }; + const props = this.props; + const store = props.store; + const eventKey = props.eventKey; + const defaultActiveFirst = store.getState().defaultActiveFirst; + let value = false; + + if (defaultActiveFirst) { + value = defaultActiveFirst[eventKey]; + } + + updateDefaultActiveFirst(store, eventKey, value); + + return {}; }, componentDidMount() { this.componentDidUpdate(); + // invoke customized ref to expose component to mixin + if (this.props.manualRef) { + this.props.manualRef(this); + } }, componentDidUpdate() { const { mode, parentMenu } = this.props; - if (mode !== 'horizontal' || !parentMenu.isRootMenu || !this.isOpen()) { + if (mode !== 'horizontal' || !parentMenu.isRootMenu || !this.props.isOpen) { return; } this.minWidthTimeout = setTimeout(() => { @@ -106,13 +137,14 @@ const SubMenu = createReactClass({ onKeyDown(e) { const keyCode = e.keyCode; const menu = this.menuInstance; - const isOpen = this.isOpen(); + const { + isOpen, + store, + } = this.props; if (keyCode === KeyCode.ENTER) { this.onTitleClick(e); - this.setState({ - defaultActiveFirst: true, - }); + updateDefaultActiveFirst(store, this.props.eventKey, true); return true; } @@ -121,9 +153,8 @@ const SubMenu = createReactClass({ menu.onKeyDown(e); } else { this.triggerOpenChange(true); - this.setState({ - defaultActiveFirst: true, - }); + // need to update current menu's defaultActiveFirst value + updateDefaultActiveFirst(store, this.props.eventKey, true); } return true; } @@ -155,10 +186,8 @@ const SubMenu = createReactClass({ }, onMouseEnter(e) { - const { eventKey: key, onMouseEnter } = this.props; - this.setState({ - defaultActiveFirst: false, - }); + const { eventKey: key, onMouseEnter, store } = this.props; + updateDefaultActiveFirst(store, this.props.eventKey, true); onMouseEnter({ key, domEvent: e, @@ -212,10 +241,8 @@ const SubMenu = createReactClass({ if (props.triggerSubMenuAction === 'hover') { return; } - this.triggerOpenChange(!this.isOpen(), 'click'); - this.setState({ - defaultActiveFirst: false, - }); + this.triggerOpenChange(!props.isOpen, 'click'); + updateDefaultActiveFirst(props.store, this.props.eventKey, true); }, onSubMenuClick(info) { @@ -251,6 +278,7 @@ const SubMenu = createReactClass({ }, saveMenuInstance(c) { + // children menu instance this.menuInstance = c; }, @@ -295,7 +323,7 @@ const SubMenu = createReactClass({ const props = this.props; const baseProps = { mode: props.mode === 'horizontal' ? 'vertical' : props.mode, - visible: this.isOpen(), + visible: this.props.isOpen, level: props.level + 1, inlineIndent: props.inlineIndent, focusable: false, @@ -313,11 +341,12 @@ const SubMenu = createReactClass({ subMenuCloseDelay: props.subMenuCloseDelay, forceSubMenuRender: props.forceSubMenuRender, triggerSubMenuAction: props.triggerSubMenuAction, - defaultActiveFirst: this.state.defaultActiveFirst, + defaultActiveFirst: props.store.getState() + .defaultActiveFirst[getMenuIdFromSubMenuEventKey(props.eventKey)], multiple: props.multiple, prefixCls: props.rootPrefixCls, id: this._menuId, - ref: this.saveMenuInstance, + manualRef: this.saveMenuInstance, }; return {children}; }, @@ -328,7 +357,7 @@ const SubMenu = createReactClass({ render() { const props = this.props; - const isOpen = this.isOpen(); + const isOpen = props.isOpen; const prefixCls = this.getPrefixCls(); const isInlineMode = props.mode === 'inline'; const className = classNames(prefixCls, `${prefixCls}-${props.mode}`, { @@ -392,6 +421,7 @@ const SubMenu = createReactClass({ props.parentMenu.props.getPopupContainer : triggerNode => triggerNode.parentNode; const popupPlacement = popupPlacementMap[props.mode]; const popupClassName = props.mode === 'inline' ? '' : props.popupClassName; + return (
  • {isInlineMode && title} @@ -421,4 +451,7 @@ const SubMenu = createReactClass({ SubMenu.isSubMenu = 1; -export default SubMenu; +export default connect(({ openKeys, activeKey }, { eventKey, subMenuKey }) => ({ + isOpen: openKeys.indexOf(eventKey) > -1, + active: activeKey[subMenuKey] === eventKey, +}))(SubMenu); diff --git a/src/SubPopupMenu.js b/src/SubPopupMenu.js index 56b81c81..76655b0e 100644 --- a/src/SubPopupMenu.js +++ b/src/SubPopupMenu.js @@ -2,7 +2,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; import Animate from 'rc-animate'; -import MenuMixin from './MenuMixin'; +import { connect } from 'mini-store'; +import { default as MenuMixin, getActiveKey } from './MenuMixin'; const SubPopupMenu = createReactClass({ displayName: 'SubPopupMenu', @@ -22,6 +23,25 @@ const SubPopupMenu = createReactClass({ mixins: [MenuMixin], + getInitialState() { + const props = this.props; + props.store.setState({ + activeKey: { + ...props.store.getState().activeKey, + [props.eventKey]: getActiveKey(props, props.activeKey), + }, + }); + + return {}; + }, + + componentDidMount() { + // invoke customized ref to expose component to mixin + if (this.props.manualRef) { + this.props.manualRef(this); + } + }, + onDeselect(selectInfo) { this.props.onDeselect(selectInfo); }, @@ -46,7 +66,7 @@ const SubPopupMenu = createReactClass({ return this.props.openTransitionName; }, - renderMenuItem(c, i, subIndex) { + renderMenuItem(c, i, subIndex, subMenuKey) { if (!c) { return null; } @@ -55,6 +75,7 @@ const SubPopupMenu = createReactClass({ openKeys: props.openKeys, selectedKeys: props.selectedKeys, triggerSubMenuAction: props.triggerSubMenuAction, + subMenuKey, }; return this.renderCommonMenuItem(c, i, subIndex, extraProps); }, @@ -96,4 +117,4 @@ const SubPopupMenu = createReactClass({ }, }); -export default SubPopupMenu; +export default connect()(SubPopupMenu); diff --git a/src/util.js b/src/util.js index acf73f9b..fbb8739e 100644 --- a/src/util.js +++ b/src/util.js @@ -8,6 +8,10 @@ export function getKeyFromChildrenIndex(child, menuEventKey, index) { return child.key || `${prefix}item_${index}`; } +export function getMenuIdFromSubMenuEventKey(eventKey) { + return `${eventKey}-menu-`; +} + export function loopMenuItem(children, cb) { let index = -1; React.Children.forEach(children, (c) => { diff --git a/tests/SubMenu.spec.js b/tests/SubMenu.spec.js index 455c7762..f89697e3 100644 --- a/tests/SubMenu.spec.js +++ b/tests/SubMenu.spec.js @@ -16,10 +16,10 @@ describe('SubMenu', () => { return ( - 1 + 1 - 2 + 2 ); @@ -34,20 +34,20 @@ describe('SubMenu', () => { ); wrapper.find('.rc-menu-submenu-title').first().simulate('mouseEnter'); - expect(wrapper.state('openKeys')).toEqual([]); + expect(wrapper.instance().store.getState().openKeys).toEqual([]); }); - describe('openSubMenuOnMouseEnter and closeSubMenuOnMouseLeave are ture', () => { + describe('openSubMenuOnMouseEnter and closeSubMenuOnMouseLeave are true', () => { it('toggles when mouse enter and leave', () => { const wrapper = mount(createMenu()); wrapper.find('.rc-menu-submenu-title').first().simulate('mouseEnter'); jest.runAllTimers(); - expect(wrapper.state('openKeys')).toEqual(['s1']); + expect(wrapper.instance().store.getState().openKeys).toEqual(['s1']); wrapper.find('.rc-menu-submenu-title').first().simulate('mouseLeave'); jest.runAllTimers(); - expect(wrapper.state('openKeys')).toEqual([]); + expect(wrapper.instance().store.getState().openKeys).toEqual([]); }); }); @@ -62,10 +62,10 @@ describe('SubMenu', () => { it('toggles when mouse click', () => { wrapper.find('.rc-menu-submenu-title').first().simulate('click'); - expect(wrapper.state('openKeys')).toEqual(['s1']); + expect(wrapper.instance().store.getState().openKeys).toEqual(['s1']); wrapper.find('.rc-menu-submenu-title').first().simulate('click'); - expect(wrapper.state('openKeys')).toEqual([]); + expect(wrapper.instance().store.getState().openKeys).toEqual([]); }); }); @@ -112,14 +112,13 @@ describe('SubMenu', () => { describe('left & right key', () => { it('toggles menu', () => { - const wrapper = mount(createMenu()); + 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.state('openKeys')).toEqual([]); + expect(wrapper.instance().store.getState().openKeys).toEqual([]); title.simulate('keyDown', { keyCode: KeyCode.RIGHT }); - expect(wrapper.state('openKeys')).toEqual(['s1']); - + expect(wrapper.instance().store.getState().openKeys).toEqual(['s1']); expect(wrapper.find('MenuItem').first().props().active).toBe(true); }); }); @@ -148,7 +147,7 @@ describe('SubMenu', () => { wrapper.update(); wrapper.find('MenuItem').first().simulate('click'); - expect(handleSelect.mock.calls[0][0].key).toBe('1'); + expect(handleSelect.mock.calls[0][0].key).toBe('s1-1'); }); it('fires deselect event for multiple menu', () => { @@ -165,6 +164,6 @@ describe('SubMenu', () => { wrapper.find('MenuItem').first().simulate('click'); wrapper.find('MenuItem').first().simulate('click'); - expect(handleDeselect.mock.calls[0][0].key).toBe('1'); + expect(handleDeselect.mock.calls[0][0].key).toBe('s1-1'); }); });