diff --git a/components/_util/StateMixin.js b/components/_util/StateMixin.js new file mode 100644 index 000000000..d6aede71a --- /dev/null +++ b/components/_util/StateMixin.js @@ -0,0 +1,10 @@ +export default { + methods: { + setState (state, callback) { + Object.assign(this.$date, state) + this.$nextTick(() => { + callback() + }) + }, + }, +} diff --git a/components/animate/index.js b/components/animate/index.js new file mode 100644 index 000000000..a99ccde37 --- /dev/null +++ b/components/animate/index.js @@ -0,0 +1,3 @@ +// do not modify this file +import Animate from './src/Animate' +export default Animate diff --git a/components/animate/src/Animate.js b/components/animate/src/Animate.js new file mode 100644 index 000000000..d58661603 --- /dev/null +++ b/components/animate/src/Animate.js @@ -0,0 +1,333 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { + toArrayChildren, + mergeChildren, + findShownChildInChildrenByKey, + findChildInChildrenByKey, + isSameChildren, +} from './ChildrenUtils' +import AnimateChild from './AnimateChild' +const defaultKey = `rc_animate_${Date.now()}` +import animUtil from './util' + +function getChildrenFromProps (props) { + const children = props.children + if (React.isValidElement(children)) { + if (!children.key) { + return React.cloneElement(children, { + key: defaultKey, + }) + } + } + return children +} + +function noop () { +} + +export default class Animate extends React.Component { + static propTypes = { + component: PropTypes.any, + componentProps: PropTypes.object, + animation: PropTypes.object, + transitionName: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.object, + ]), + transitionEnter: PropTypes.bool, + transitionAppear: PropTypes.bool, + exclusive: PropTypes.bool, + transitionLeave: PropTypes.bool, + onEnd: PropTypes.func, + onEnter: PropTypes.func, + onLeave: PropTypes.func, + onAppear: PropTypes.func, + showProp: PropTypes.string, + } + + static defaultProps = { + animation: {}, + component: 'span', + componentProps: {}, + transitionEnter: true, + transitionLeave: true, + transitionAppear: false, + onEnd: noop, + onEnter: noop, + onLeave: noop, + onAppear: noop, + } + + constructor (props) { + super(props) + + this.currentlyAnimatingKeys = {} + this.keysToEnter = [] + this.keysToLeave = [] + + this.state = { + children: toArrayChildren(getChildrenFromProps(this.props)), + } + + this.childrenRefs = {} + } + + componentDidMount () { + const showProp = this.props.showProp + let children = this.state.children + if (showProp) { + children = children.filter((child) => { + return !!child.props[showProp] + }) + } + children.forEach((child) => { + if (child) { + this.performAppear(child.key) + } + }) + } + + componentWillReceiveProps (nextProps) { + this.nextProps = nextProps + const nextChildren = toArrayChildren(getChildrenFromProps(nextProps)) + const props = this.props + // exclusive needs immediate response + if (props.exclusive) { + Object.keys(this.currentlyAnimatingKeys).forEach((key) => { + this.stop(key) + }) + } + const showProp = props.showProp + const currentlyAnimatingKeys = this.currentlyAnimatingKeys + // last props children if exclusive + const currentChildren = props.exclusive + ? toArrayChildren(getChildrenFromProps(props)) + : this.state.children + // in case destroy in showProp mode + let newChildren = [] + if (showProp) { + currentChildren.forEach((currentChild) => { + const nextChild = currentChild && findChildInChildrenByKey(nextChildren, currentChild.key) + let newChild + if ((!nextChild || !nextChild.props[showProp]) && currentChild.props[showProp]) { + newChild = React.cloneElement(nextChild || currentChild, { + [showProp]: true, + }) + } else { + newChild = nextChild + } + if (newChild) { + newChildren.push(newChild) + } + }) + nextChildren.forEach((nextChild) => { + if (!nextChild || !findChildInChildrenByKey(currentChildren, nextChild.key)) { + newChildren.push(nextChild) + } + }) + } else { + newChildren = mergeChildren( + currentChildren, + nextChildren + ) + } + + // need render to avoid update + this.setState({ + children: newChildren, + }) + + nextChildren.forEach((child) => { + const key = child && child.key + if (child && currentlyAnimatingKeys[key]) { + return + } + const hasPrev = child && findChildInChildrenByKey(currentChildren, key) + if (showProp) { + const showInNext = child.props[showProp] + if (hasPrev) { + const showInNow = findShownChildInChildrenByKey(currentChildren, key, showProp) + if (!showInNow && showInNext) { + this.keysToEnter.push(key) + } + } else if (showInNext) { + this.keysToEnter.push(key) + } + } else if (!hasPrev) { + this.keysToEnter.push(key) + } + }) + + currentChildren.forEach((child) => { + const key = child && child.key + if (child && currentlyAnimatingKeys[key]) { + return + } + const hasNext = child && findChildInChildrenByKey(nextChildren, key) + if (showProp) { + const showInNow = child.props[showProp] + if (hasNext) { + const showInNext = findShownChildInChildrenByKey(nextChildren, key, showProp) + if (!showInNext && showInNow) { + this.keysToLeave.push(key) + } + } else if (showInNow) { + this.keysToLeave.push(key) + } + } else if (!hasNext) { + this.keysToLeave.push(key) + } + }) + } + + componentDidUpdate () { + const keysToEnter = this.keysToEnter + this.keysToEnter = [] + keysToEnter.forEach(this.performEnter) + const keysToLeave = this.keysToLeave + this.keysToLeave = [] + keysToLeave.forEach(this.performLeave) + } + + performEnter = (key) => { + // may already remove by exclusive + if (this.childrenRefs[key]) { + this.currentlyAnimatingKeys[key] = true + this.childrenRefs[key].componentWillEnter( + this.handleDoneAdding.bind(this, key, 'enter') + ) + } + } + + performAppear = (key) => { + if (this.childrenRefs[key]) { + this.currentlyAnimatingKeys[key] = true + this.childrenRefs[key].componentWillAppear( + this.handleDoneAdding.bind(this, key, 'appear') + ) + } + } + + handleDoneAdding = (key, type) => { + const props = this.props + delete this.currentlyAnimatingKeys[key] + // if update on exclusive mode, skip check + if (props.exclusive && props !== this.nextProps) { + return + } + const currentChildren = toArrayChildren(getChildrenFromProps(props)) + if (!this.isValidChildByKey(currentChildren, key)) { + // exclusive will not need this + this.performLeave(key) + } else { + if (type === 'appear') { + if (animUtil.allowAppearCallback(props)) { + props.onAppear(key) + props.onEnd(key, true) + } + } else { + if (animUtil.allowEnterCallback(props)) { + props.onEnter(key) + props.onEnd(key, true) + } + } + } + } + + performLeave = (key) => { + // may already remove by exclusive + if (this.childrenRefs[key]) { + this.currentlyAnimatingKeys[key] = true + this.childrenRefs[key].componentWillLeave(this.handleDoneLeaving.bind(this, key)) + } + } + + handleDoneLeaving = (key) => { + const props = this.props + delete this.currentlyAnimatingKeys[key] + // if update on exclusive mode, skip check + if (props.exclusive && props !== this.nextProps) { + return + } + const currentChildren = toArrayChildren(getChildrenFromProps(props)) + // in case state change is too fast + if (this.isValidChildByKey(currentChildren, key)) { + this.performEnter(key) + } else { + const end = () => { + if (animUtil.allowLeaveCallback(props)) { + props.onLeave(key) + props.onEnd(key, false) + } + } + if (!isSameChildren(this.state.children, + currentChildren, props.showProp)) { + this.setState({ + children: currentChildren, + }, end) + } else { + end() + } + } + } + + isValidChildByKey (currentChildren, key) { + const showProp = this.props.showProp + if (showProp) { + return findShownChildInChildrenByKey(currentChildren, key, showProp) + } + return findChildInChildrenByKey(currentChildren, key) + } + + stop (key) { + delete this.currentlyAnimatingKeys[key] + const component = this.childrenRefs[key] + if (component) { + component.stop() + } + } + + render () { + const props = this.props + this.nextProps = props + const stateChildren = this.state.children + let children = null + if (stateChildren) { + children = stateChildren.map((child) => { + if (child === null || child === undefined) { + return child + } + if (!child.key) { + throw new Error('must set key for children') + } + return ( + this.childrenRefs[child.key] = node} + animation={props.animation} + transitionName={props.transitionName} + transitionEnter={props.transitionEnter} + transitionAppear={props.transitionAppear} + transitionLeave={props.transitionLeave} + > + {child} + + ) + }) + } + const Component = props.component + if (Component) { + let passedProps = props + if (typeof Component === 'string') { + passedProps = { + className: props.className, + style: props.style, + ...props.componentProps, + } + } + return {children} + } + return children[0] || null + } +} diff --git a/components/animate/src/AnimateChild.js b/components/animate/src/AnimateChild.js new file mode 100644 index 000000000..3cbeacd5c --- /dev/null +++ b/components/animate/src/AnimateChild.js @@ -0,0 +1,86 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import PropTypes from 'prop-types' +import cssAnimate, { isCssAnimationSupported } from 'css-animation' +import animUtil from './util' + +const transitionMap = { + enter: 'transitionEnter', + appear: 'transitionAppear', + leave: 'transitionLeave', +} + +export default class AnimateChild extends React.Component { + static propTypes = { + children: PropTypes.any, + } + + componentWillUnmount () { + this.stop() + } + + componentWillEnter (done) { + if (animUtil.isEnterSupported(this.props)) { + this.transition('enter', done) + } else { + done() + } + } + + componentWillAppear (done) { + if (animUtil.isAppearSupported(this.props)) { + this.transition('appear', done) + } else { + done() + } + } + + componentWillLeave (done) { + if (animUtil.isLeaveSupported(this.props)) { + this.transition('leave', done) + } else { + // always sync, do not interupt with react component life cycle + // update hidden -> animate hidden -> + // didUpdate -> animate leave -> unmount (if animate is none) + done() + } + } + + transition (animationType, finishCallback) { + const node = ReactDOM.findDOMNode(this) + const props = this.props + const transitionName = props.transitionName + const nameIsObj = typeof transitionName === 'object' + this.stop() + const end = () => { + this.stopper = null + finishCallback() + } + if ((isCssAnimationSupported || !props.animation[animationType]) && + transitionName && props[transitionMap[animationType]]) { + const name = nameIsObj ? transitionName[animationType] : `${transitionName}-${animationType}` + let activeName = `${name}-active` + if (nameIsObj && transitionName[`${animationType}Active`]) { + activeName = transitionName[`${animationType}Active`] + } + this.stopper = cssAnimate(node, { + name, + active: activeName, + }, end) + } else { + this.stopper = props.animation[animationType](node, end) + } + } + + stop () { + const stopper = this.stopper + if (stopper) { + this.stopper = null + stopper.stop() + } + } + + render () { + return this.props.children + } +} diff --git a/components/animate/src/ChildrenUtils.js b/components/animate/src/ChildrenUtils.js new file mode 100644 index 000000000..b9cc639cd --- /dev/null +++ b/components/animate/src/ChildrenUtils.js @@ -0,0 +1,101 @@ +import React from 'react' + +export function toArrayChildren (children) { + const ret = [] + React.Children.forEach(children, (child) => { + ret.push(child) + }) + return ret +} + +export function findChildInChildrenByKey (children, key) { + let ret = null + if (children) { + children.forEach((child) => { + if (ret) { + return + } + if (child && child.key === key) { + ret = child + } + }) + } + return ret +} + +export function findShownChildInChildrenByKey (children, key, showProp) { + let ret = null + if (children) { + children.forEach((child) => { + if (child && child.key === key && child.props[showProp]) { + if (ret) { + throw new Error('two child with same key for children') + } + ret = child + } + }) + } + return ret +} + +export function findHiddenChildInChildrenByKey (children, key, showProp) { + let found = 0 + if (children) { + children.forEach((child) => { + if (found) { + return + } + found = child && child.key === key && !child.props[showProp] + }) + } + return found +} + +export function isSameChildren (c1, c2, showProp) { + let same = c1.length === c2.length + if (same) { + c1.forEach((child, index) => { + const child2 = c2[index] + if (child && child2) { + if ((child && !child2) || (!child && child2)) { + same = false + } else if (child.key !== child2.key) { + same = false + } else if (showProp && child.props[showProp] !== child2.props[showProp]) { + same = false + } + } + }) + } + return same +} + +export function mergeChildren (prev, next) { + let ret = [] + + // For each key of `next`, the list of keys to insert before that key in + // the combined list + const nextChildrenPending = {} + let pendingChildren = [] + prev.forEach((child) => { + if (child && findChildInChildrenByKey(next, child.key)) { + if (pendingChildren.length) { + nextChildrenPending[child.key] = pendingChildren + pendingChildren = [] + } + } else { + pendingChildren.push(child) + } + }) + + next.forEach((child) => { + if (child && nextChildrenPending.hasOwnProperty(child.key)) { + ret = ret.concat(nextChildrenPending[child.key]) + } + ret.push(child) + }) + + ret = ret.concat(pendingChildren) + + return ret +} diff --git a/components/animate/src/util.js b/components/animate/src/util.js new file mode 100644 index 000000000..d84f1e662 --- /dev/null +++ b/components/animate/src/util.js @@ -0,0 +1,21 @@ +const util = { + isAppearSupported(props) { + return props.transitionName && props.transitionAppear || props.animation.appear; + }, + isEnterSupported(props) { + return props.transitionName && props.transitionEnter || props.animation.enter; + }, + isLeaveSupported(props) { + return props.transitionName && props.transitionLeave || props.animation.leave; + }, + allowAppearCallback(props) { + return props.transitionAppear || props.animation.appear; + }, + allowEnterCallback(props) { + return props.transitionEnter || props.animation.enter; + }, + allowLeaveCallback(props) { + return props.transitionLeave || props.animation.leave; + }, +}; +export default util; diff --git a/components/menu/index.js b/components/menu/index.js new file mode 100644 index 000000000..e69de29bb diff --git a/components/menu/src/DOMWrap.vue b/components/menu/src/DOMWrap.vue new file mode 100644 index 000000000..555963b25 --- /dev/null +++ b/components/menu/src/DOMWrap.vue @@ -0,0 +1,38 @@ + + diff --git a/components/menu/src/Divider.vue b/components/menu/src/Divider.vue new file mode 100644 index 000000000..b115897d4 --- /dev/null +++ b/components/menu/src/Divider.vue @@ -0,0 +1,16 @@ + diff --git a/components/menu/src/Menu.jsx b/components/menu/src/Menu.jsx new file mode 100644 index 000000000..1ad164b0c --- /dev/null +++ b/components/menu/src/Menu.jsx @@ -0,0 +1,221 @@ +// import React from 'react'; +import PropTypes from 'prop-types' +import createReactClass from 'create-react-class' +import MenuMixin from './MenuMixin' +import { noop } from './util' + +const Menu = createReactClass({ + displayName: 'Menu', + + propTypes: { + defaultSelectedKeys: PropTypes.arrayOf(PropTypes.string), + selectedKeys: PropTypes.arrayOf(PropTypes.string), + defaultOpenKeys: PropTypes.arrayOf(PropTypes.string), + openKeys: PropTypes.arrayOf(PropTypes.string), + mode: PropTypes.oneOf(['horizontal', 'vertical', 'vertical-left', 'vertical-right', 'inline']), + getPopupContainer: PropTypes.func, + onClick: PropTypes.func, + onSelect: PropTypes.func, + onDeselect: PropTypes.func, + onDestroy: PropTypes.func, + openTransitionName: PropTypes.string, + openAnimation: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + subMenuOpenDelay: PropTypes.number, + subMenuCloseDelay: PropTypes.number, + forceSubMenuRender: PropTypes.bool, + triggerSubMenuAction: PropTypes.string, + level: PropTypes.number, + selectable: PropTypes.bool, + multiple: PropTypes.bool, + children: PropTypes.any, + }, + + mixins: [MenuMixin], + + isRootMenu: true, + + getDefaultProps () { + return { + selectable: true, + onClick: noop, + onSelect: noop, + onOpenChange: noop, + onDeselect: noop, + defaultSelectedKeys: [], + defaultOpenKeys: [], + subMenuOpenDelay: 0, + subMenuCloseDelay: 0.1, + triggerSubMenuAction: 'hover', + } + }, + + getInitialState () { + const props = this.props + let selectedKeys = props.defaultSelectedKeys + let openKeys = props.defaultOpenKeys + if ('selectedKeys' in props) { + selectedKeys = props.selectedKeys || [] + } + if ('openKeys' in props) { + openKeys = props.openKeys || [] + } + return { + selectedKeys, + openKeys, + } + }, + + componentWillReceiveProps (nextProps) { + const props = {} + if ('selectedKeys' in nextProps) { + props.selectedKeys = nextProps.selectedKeys || [] + } + if ('openKeys' in nextProps) { + props.openKeys = nextProps.openKeys || [] + } + this.setState(props) + }, + + onDestroy (key) { + const state = this.state + const props = this.props + const selectedKeys = state.selectedKeys + const openKeys = state.openKeys + let index = selectedKeys.indexOf(key) + if (!('selectedKeys' in props) && index !== -1) { + selectedKeys.splice(index, 1) + } + index = openKeys.indexOf(key) + if (!('openKeys' in props) && index !== -1) { + openKeys.splice(index, 1) + } + }, + + onSelect (selectInfo) { + const props = this.props + if (props.selectable) { + // root menu + let selectedKeys = this.state.selectedKeys + const selectedKey = selectInfo.key + if (props.multiple) { + selectedKeys = selectedKeys.concat([selectedKey]) + } else { + selectedKeys = [selectedKey] + } + if (!('selectedKeys' in props)) { + this.setState({ + selectedKeys, + }) + } + props.onSelect({ + ...selectInfo, + selectedKeys, + }) + } + }, + + onClick (e) { + this.props.onClick(e) + }, + + onOpenChange (e_) { + const props = this.props + const openKeys = this.state.openKeys.concat() + let changed = false + const processSingle = (e) => { + let oneChanged = false + if (e.open) { + oneChanged = openKeys.indexOf(e.key) === -1 + if (oneChanged) { + openKeys.push(e.key) + } + } else { + const index = openKeys.indexOf(e.key) + oneChanged = index !== -1 + if (oneChanged) { + openKeys.splice(index, 1) + } + } + changed = changed || oneChanged + } + if (Array.isArray(e_)) { + // batch change call + e_.forEach(processSingle) + } else { + processSingle(e_) + } + if (changed) { + if (!('openKeys' in this.props)) { + this.setState({ openKeys }) + } + props.onOpenChange(openKeys) + } + }, + + onDeselect (selectInfo) { + const props = this.props + if (props.selectable) { + const selectedKeys = this.state.selectedKeys.concat() + const selectedKey = selectInfo.key + const index = selectedKeys.indexOf(selectedKey) + if (index !== -1) { + selectedKeys.splice(index, 1) + } + if (!('selectedKeys' in props)) { + this.setState({ + selectedKeys, + }) + } + props.onDeselect({ + ...selectInfo, + selectedKeys, + }) + } + }, + + getOpenTransitionName () { + const props = this.props + let transitionName = props.openTransitionName + const animationName = props.openAnimation + if (!transitionName && typeof animationName === 'string') { + transitionName = `${props.prefixCls}-open-${animationName}` + } + return transitionName + }, + + isInlineMode () { + return this.props.mode === 'inline' + }, + + lastOpenSubMenu () { + let lastOpen = [] + const { openKeys } = this.state + if (openKeys.length) { + lastOpen = this.getFlatInstanceArray().filter((c) => { + return c && openKeys.indexOf(c.props.eventKey) !== -1 + }) + } + return lastOpen[0] + }, + + renderMenuItem (c, i, subIndex) { + if (!c) { + return null + } + const state = this.state + const extraProps = { + openKeys: state.openKeys, + selectedKeys: state.selectedKeys, + triggerSubMenuAction: this.props.triggerSubMenuAction, + } + return this.renderCommonMenuItem(c, i, subIndex, extraProps) + }, + + render () { + const props = { ...this.props } + props.className += ` ${props.prefixCls}-root` + return this.renderRoot(props) + }, +}) + +export default Menu diff --git a/components/menu/src/MenuItem.jsx b/components/menu/src/MenuItem.jsx new file mode 100644 index 000000000..915405ed5 --- /dev/null +++ b/components/menu/src/MenuItem.jsx @@ -0,0 +1,165 @@ +import React from 'react' +import PropTypes from 'prop-types' +import createReactClass from 'create-react-class' +import KeyCode from 'rc-util/lib/KeyCode' +import classNames from 'classnames' +import { noop } from './util' + +/* eslint react/no-is-mounted:0 */ + +const MenuItem = createReactClass({ + displayName: 'MenuItem', + + propTypes: { + rootPrefixCls: PropTypes.string, + eventKey: PropTypes.string, + active: PropTypes.bool, + children: PropTypes.any, + selectedKeys: PropTypes.array, + disabled: PropTypes.bool, + title: PropTypes.string, + onItemHover: PropTypes.func, + onSelect: PropTypes.func, + onClick: PropTypes.func, + onDeselect: PropTypes.func, + parentMenu: PropTypes.object, + onDestroy: PropTypes.func, + onMouseEnter: PropTypes.func, + onMouseLeave: PropTypes.func, + }, + + getDefaultProps () { + return { + onSelect: noop, + onMouseEnter: noop, + onMouseLeave: noop, + } + }, + + componentWillUnmount () { + const props = this.props + if (props.onDestroy) { + props.onDestroy(props.eventKey) + } + }, + onKeyDown (e) { + const keyCode = e.keyCode + if (keyCode === KeyCode.ENTER) { + this.onClick(e) + return true + } + }, + + onMouseLeave (e) { + const { eventKey, onItemHover, onMouseLeave } = this.props + onItemHover({ + key: eventKey, + hover: false, + }) + onMouseLeave({ + key: eventKey, + domEvent: e, + }) + }, + + onMouseEnter (e) { + const { eventKey, parentMenu, onItemHover, onMouseEnter } = this.props + if (parentMenu.subMenuInstance) { + parentMenu.subMenuInstance.clearSubMenuTimers() + } + onItemHover({ + key: eventKey, + hover: true, + }) + onMouseEnter({ + key: eventKey, + domEvent: e, + }) + }, + + onClick (e) { + const { eventKey, multiple, onClick, onSelect, onDeselect } = this.props + const selected = this.isSelected() + const info = { + key: eventKey, + keyPath: [eventKey], + item: this, + domEvent: e, + } + onClick(info) + if (multiple) { + if (selected) { + onDeselect(info) + } else { + onSelect(info) + } + } else if (!selected) { + onSelect(info) + } + }, + + getPrefixCls () { + return `${this.props.rootPrefixCls}-item` + }, + + getActiveClassName () { + return `${this.getPrefixCls()}-active` + }, + + getSelectedClassName () { + return `${this.getPrefixCls()}-selected` + }, + + getDisabledClassName () { + 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.getDisabledClassName()]: props.disabled, + }) + const attrs = { + ...props.attribute, + title: props.title, + className, + role: 'menuitem', + 'aria-selected': selected, + 'aria-disabled': props.disabled, + } + let mouseEvent = {} + if (!props.disabled) { + mouseEvent = { + onClick: this.onClick, + onMouseLeave: this.onMouseLeave, + onMouseEnter: this.onMouseEnter, + } + } + const style = { + ...props.style, + } + if (props.mode === 'inline') { + style.paddingLeft = props.inlineIndent * props.level + } + return ( +
  • + {props.children} +
  • + ) + }, +}) + +MenuItem.isMenuItem = 1 + +export default MenuItem diff --git a/components/menu/src/MenuItemGroup.jsx b/components/menu/src/MenuItemGroup.jsx new file mode 100644 index 000000000..0e81e0984 --- /dev/null +++ b/components/menu/src/MenuItemGroup.jsx @@ -0,0 +1,48 @@ +import React from 'react' +import PropTypes from 'prop-types' +import createReactClass from 'create-react-class' + +const MenuItemGroup = createReactClass({ + displayName: 'MenuItemGroup', + + propTypes: { + renderMenuItem: PropTypes.func, + index: PropTypes.number, + className: PropTypes.string, + rootPrefixCls: PropTypes.string, + }, + + getDefaultProps () { + // To fix keyboard UX. + return { disabled: true } + }, + + renderInnerMenuItem (item, subIndex) { + const { renderMenuItem, index } = this.props + return renderMenuItem(item, index, subIndex) + }, + + render () { + const props = this.props + const { className = '', rootPrefixCls } = props + const titleClassName = `${rootPrefixCls}-item-group-title` + const listClassName = `${rootPrefixCls}-item-group-list` + return ( +
  • +
    + {props.title} +
    +
      + {React.Children.map(props.children, this.renderInnerMenuItem)} +
    +
  • + ) + }, +}) + +MenuItemGroup.isMenuItemGroup = true + +export default MenuItemGroup diff --git a/components/menu/src/MenuMixin.js b/components/menu/src/MenuMixin.js new file mode 100644 index 000000000..a222209fa --- /dev/null +++ b/components/menu/src/MenuMixin.js @@ -0,0 +1,298 @@ +import React from 'react' +import PropTypes from 'prop-types' +import ReactDOM from 'react-dom' +import KeyCode from 'rc-util/lib/KeyCode' +import createChainedFunction from 'rc-util/lib/createChainedFunction' +import classNames from 'classnames' +import scrollIntoView from 'dom-scroll-into-view' +import { getKeyFromChildrenIndex, loopMenuItem } from './util' +import DOMWrap from './DOMWrap' + +function allDisabled (arr) { + if (!arr.length) { + return true + } + return arr.every(c => !!c.props.disabled) +} + +function getActiveKey (props, originalActiveKey) { + let activeKey = originalActiveKey + const { children, eventKey } = props + if (activeKey) { + let found + loopMenuItem(children, (c, i) => { + if (c && !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 +} + +function saveRef (index, subIndex, c) { + if (c) { + if (subIndex !== undefined) { + this.instanceArray[index] = this.instanceArray[index] || [] + this.instanceArray[index][subIndex] = c + } else { + this.instanceArray[index] = c + } + } +} + +const MenuMixin = { + propTypes: { + focusable: PropTypes.bool, + multiple: PropTypes.bool, + style: PropTypes.object, + defaultActiveFirst: PropTypes.bool, + visible: PropTypes.bool, + activeKey: PropTypes.string, + selectedKeys: PropTypes.arrayOf(PropTypes.string), + defaultSelectedKeys: PropTypes.arrayOf(PropTypes.string), + defaultOpenKeys: PropTypes.arrayOf(PropTypes.string), + openKeys: PropTypes.arrayOf(PropTypes.string), + children: PropTypes.any, + }, + + getDefaultProps () { + return { + prefixCls: 'rc-menu', + className: '', + mode: 'vertical', + level: 1, + inlineIndent: 24, + visible: true, + focusable: true, + style: {}, + } + }, + + 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) + } + }, + + shouldComponentUpdate (nextProps) { + return this.props.visible || nextProps.visible + }, + + componentWillMount () { + this.instanceArray = [] + }, + + // all keyboard events callbacks run from here at first + onKeyDown (e, callback) { + const keyCode = e.keyCode + let handled + this.getFlatInstanceArray().forEach((obj) => { + if (obj && obj.props.active && obj.onKeyDown) { + handled = obj.onKeyDown(e) + } + }) + if (handled) { + return 1 + } + let activeItem = null + if (keyCode === KeyCode.UP || keyCode === KeyCode.DOWN) { + activeItem = this.step(keyCode === KeyCode.UP ? -1 : 1) + } + 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) + } + }) + return 1 + } else if (activeItem === undefined) { + e.preventDefault() + this.setState({ + activeKey: null, + }) + return 1 + } + }, + + onItemHover (e) { + const { key, hover } = e + this.setState({ + activeKey: hover ? key : null, + }) + }, + + getFlatInstanceArray () { + let instanceArray = this.instanceArray + const hasInnerArray = instanceArray.some((a) => { + return Array.isArray(a) + }) + if (hasInnerArray) { + instanceArray = [] + this.instanceArray.forEach((a) => { + if (Array.isArray(a)) { + instanceArray.push.apply(instanceArray, a) + } else { + instanceArray.push(a) + } + }) + this.instanceArray = instanceArray + } + return instanceArray + }, + + renderCommonMenuItem (child, i, subIndex, extraProps) { + const state = this.state + const props = this.props + const key = getKeyFromChildrenIndex(child, props.eventKey, i) + const childProps = child.props + const isActive = key === state.activeKey + const newChildProps = { + mode: props.mode, + level: props.level, + inlineIndent: props.inlineIndent, + renderMenuItem: this.renderMenuItem, + rootPrefixCls: props.prefixCls, + index: i, + parentMenu: this, + ref: childProps.disabled ? undefined + : createChainedFunction(child.ref, saveRef.bind(this, i, subIndex)), + eventKey: key, + active: !childProps.disabled && isActive, + multiple: props.multiple, + onClick: this.onClick, + onItemHover: this.onItemHover, + openTransitionName: this.getOpenTransitionName(), + openAnimation: props.openAnimation, + subMenuOpenDelay: props.subMenuOpenDelay, + subMenuCloseDelay: props.subMenuCloseDelay, + forceSubMenuRender: props.forceSubMenuRender, + onOpenChange: this.onOpenChange, + onDeselect: this.onDeselect, + onDestroy: this.onDestroy, + onSelect: this.onSelect, + ...extraProps, + } + if (props.mode === 'inline') { + newChildProps.triggerSubMenuAction = 'click' + } + return React.cloneElement(child, newChildProps) + }, + + renderRoot (props) { + this.instanceArray = [] + const className = classNames( + props.prefixCls, + props.className, + `${props.prefixCls}-${props.mode}`, + ) + const domProps = { + className, + role: 'menu', + 'aria-activedescendant': '', + } + if (props.id) { + domProps.id = props.id + } + if (props.focusable) { + domProps.tabIndex = '0' + domProps.onKeyDown = this.onKeyDown + } + return ( + // ESLint is not smart enough to know that the type of `children` was checked. + /* eslint-disable */ + + {React.Children.map(props.children, this.renderMenuItem)} + + /*eslint -enable */ + ) + }, + + step (direction) { + let children = this.getFlatInstanceArray() + const activeKey = this.state.activeKey + 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) { + if (allDisabled(children.slice(activeIndex, len - 1))) { + return undefined + } + } + const start = (activeIndex + 1) % len + let i = start + for (; ;) { + const child = children[i] + if (!child || child.props.disabled) { + i = (i + 1 + len) % len + // complete a loop + if (i === start) { + return null + } + } else { + return child + } + } + }, +} + +export default MenuMixin diff --git a/components/menu/src/SubMenu.jsx b/components/menu/src/SubMenu.jsx new file mode 100644 index 000000000..ccf6be35a --- /dev/null +++ b/components/menu/src/SubMenu.jsx @@ -0,0 +1,443 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import PropTypes from 'prop-types' +import createReactClass from 'create-react-class' +import Trigger from 'rc-trigger' +import KeyCode from 'rc-util/lib/KeyCode' +import classNames from 'classnames' +import SubPopupMenu from './SubPopupMenu' +import placements from './placements' +import { noop, loopMenuItemRecusively } from './util' + +let guid = 0 + +const popupPlacementMap = { + horizontal: 'bottomLeft', + vertical: 'rightTop', + 'vertical-left': 'rightTop', + 'vertical-right': 'leftTop', +} + +const SubMenu = createReactClass({ + displayName: 'SubMenu', + + propTypes: { + parentMenu: PropTypes.object, + title: PropTypes.node, + children: PropTypes.any, + selectedKeys: PropTypes.array, + openKeys: PropTypes.array, + onClick: PropTypes.func, + onOpenChange: PropTypes.func, + rootPrefixCls: PropTypes.string, + eventKey: PropTypes.string, + multiple: PropTypes.bool, + active: PropTypes.bool, // TODO: remove + onItemHover: PropTypes.func, + onSelect: PropTypes.func, + triggerSubMenuAction: PropTypes.string, + onDeselect: PropTypes.func, + onDestroy: PropTypes.func, + onMouseEnter: PropTypes.func, + onMouseLeave: PropTypes.func, + onTitleMouseEnter: PropTypes.func, + onTitleMouseLeave: PropTypes.func, + onTitleClick: PropTypes.func, + }, + + isRootMenu: false, + + getDefaultProps () { + return { + onMouseEnter: noop, + onMouseLeave: noop, + onTitleMouseEnter: noop, + onTitleMouseLeave: noop, + onTitleClick: noop, + title: '', + } + }, + + getInitialState () { + this.isSubMenu = 1 + return { + defaultActiveFirst: false, + } + }, + + componentDidMount () { + this.componentDidUpdate() + }, + + componentDidUpdate () { + const { mode, parentMenu } = this.props + if (mode !== 'horizontal' || !parentMenu.isRootMenu || !this.isOpen()) { + return + } + setTimeout(() => { + if (!this.subMenuTitle || !this.menuInstance) { + return + } + const popupMenu = ReactDOM.findDOMNode(this.menuInstance) + if (popupMenu.offsetWidth >= this.subMenuTitle.offsetWidth) { + return + } + popupMenu.style.minWidth = `${this.subMenuTitle.offsetWidth}px` + }, 0) + }, + + componentWillUnmount () { + const { onDestroy, eventKey, parentMenu } = this.props + if (onDestroy) { + onDestroy(eventKey) + } + if (parentMenu.subMenuInstance === this) { + this.clearSubMenuTimers() + } + }, + + onDestroy (key) { + this.props.onDestroy(key) + }, + + onKeyDown (e) { + const keyCode = e.keyCode + const menu = this.menuInstance + const isOpen = this.isOpen() + + if (keyCode === KeyCode.ENTER) { + this.onTitleClick(e) + this.setState({ + defaultActiveFirst: true, + }) + return true + } + + if (keyCode === KeyCode.RIGHT) { + if (isOpen) { + menu.onKeyDown(e) + } else { + this.triggerOpenChange(true) + this.setState({ + defaultActiveFirst: true, + }) + } + return true + } + if (keyCode === KeyCode.LEFT) { + let handled + if (isOpen) { + handled = menu.onKeyDown(e) + } else { + return undefined + } + if (!handled) { + this.triggerOpenChange(false) + handled = true + } + return handled + } + + if (isOpen && (keyCode === KeyCode.UP || keyCode === KeyCode.DOWN)) { + return menu.onKeyDown(e) + } + }, + + onOpenChange (e) { + this.props.onOpenChange(e) + }, + + onPopupVisibleChange (visible) { + this.triggerOpenChange(visible, visible ? 'mouseenter' : 'mouseleave') + }, + + onMouseEnter (e) { + const { eventKey: key, onMouseEnter } = this.props + this.clearSubMenuLeaveTimer() + this.setState({ + defaultActiveFirst: false, + }) + onMouseEnter({ + key, + domEvent: e, + }) + }, + + onMouseLeave (e) { + const { + parentMenu, + eventKey, + onMouseLeave, + } = this.props + parentMenu.subMenuInstance = this + parentMenu.subMenuLeaveFn = () => { + // trigger mouseleave + onMouseLeave({ + key: eventKey, + domEvent: e, + }) + } + // prevent popup menu and submenu gap + parentMenu.subMenuLeaveTimer = setTimeout(parentMenu.subMenuLeaveFn, 100) + }, + + onTitleMouseEnter (domEvent) { + const { eventKey: key, onItemHover, onTitleMouseEnter } = this.props + this.clearSubMenuTitleLeaveTimer() + onItemHover({ + key, + hover: true, + }) + onTitleMouseEnter({ + key, + domEvent, + }) + }, + + onTitleMouseLeave (e) { + const { parentMenu, eventKey, onItemHover, onTitleMouseLeave } = this.props + parentMenu.subMenuInstance = this + parentMenu.subMenuTitleLeaveFn = () => { + onItemHover({ + key: eventKey, + hover: false, + }) + onTitleMouseLeave({ + key: eventKey, + domEvent: e, + }) + } + parentMenu.subMenuTitleLeaveTimer = setTimeout(parentMenu.subMenuTitleLeaveFn, 100) + }, + + onTitleClick (e) { + const { props } = this + props.onTitleClick({ + key: props.eventKey, + domEvent: e, + }) + if (props.triggerSubMenuAction === 'hover') { + return + } + this.triggerOpenChange(!this.isOpen(), 'click') + this.setState({ + defaultActiveFirst: false, + }) + }, + + onSubMenuClick (info) { + this.props.onClick(this.addKeyPath(info)) + }, + + onSelect (info) { + this.props.onSelect(info) + }, + + onDeselect (info) { + this.props.onDeselect(info) + }, + + getPrefixCls () { + return `${this.props.rootPrefixCls}-submenu` + }, + + getActiveClassName () { + return `${this.getPrefixCls()}-active` + }, + + getDisabledClassName () { + return `${this.getPrefixCls()}-disabled` + }, + + getSelectedClassName () { + return `${this.getPrefixCls()}-selected` + }, + + getOpenClassName () { + return `${this.props.rootPrefixCls}-submenu-open` + }, + + saveMenuInstance (c) { + this.menuInstance = c + }, + + addKeyPath (info) { + return { + ...info, + keyPath: (info.keyPath || []).concat(this.props.eventKey), + } + }, + + triggerOpenChange (open, type) { + const key = this.props.eventKey + this.onOpenChange({ + key, + item: this, + trigger: type, + open, + }) + }, + + clearSubMenuTimers () { + this.clearSubMenuLeaveTimer() + this.clearSubMenuTitleLeaveTimer() + }, + + clearSubMenuTitleLeaveTimer () { + const parentMenu = this.props.parentMenu + if (parentMenu.subMenuTitleLeaveTimer) { + clearTimeout(parentMenu.subMenuTitleLeaveTimer) + parentMenu.subMenuTitleLeaveTimer = null + parentMenu.subMenuTitleLeaveFn = null + } + }, + + clearSubMenuLeaveTimer () { + const parentMenu = this.props.parentMenu + if (parentMenu.subMenuLeaveTimer) { + clearTimeout(parentMenu.subMenuLeaveTimer) + parentMenu.subMenuLeaveTimer = null + parentMenu.subMenuLeaveFn = null + } + }, + + isChildrenSelected () { + const ret = { find: false } + loopMenuItemRecusively(this.props.children, this.props.selectedKeys, ret) + return ret.find + }, + isOpen () { + return this.props.openKeys.indexOf(this.props.eventKey) !== -1 + }, + + renderChildren (children) { + const props = this.props + const baseProps = { + mode: props.mode === 'horizontal' ? 'vertical' : props.mode, + visible: this.isOpen(), + level: props.level + 1, + inlineIndent: props.inlineIndent, + focusable: false, + onClick: this.onSubMenuClick, + onSelect: this.onSelect, + onDeselect: this.onDeselect, + onDestroy: this.onDestroy, + selectedKeys: props.selectedKeys, + eventKey: `${props.eventKey}-menu-`, + openKeys: props.openKeys, + openTransitionName: props.openTransitionName, + openAnimation: props.openAnimation, + onOpenChange: this.onOpenChange, + subMenuOpenDelay: props.subMenuOpenDelay, + subMenuCloseDelay: props.subMenuCloseDelay, + forceSubMenuRender: props.forceSubMenuRender, + triggerSubMenuAction: props.triggerSubMenuAction, + defaultActiveFirst: this.state.defaultActiveFirst, + multiple: props.multiple, + prefixCls: props.rootPrefixCls, + id: this._menuId, + ref: this.saveMenuInstance, + } + return {children} + }, + + saveSubMenuTitle (subMenuTitle) { + this.subMenuTitle = subMenuTitle + }, + + render () { + const props = this.props + const isOpen = this.isOpen() + const prefixCls = this.getPrefixCls() + const isInlineMode = props.mode === 'inline' + const className = classNames(prefixCls, `${prefixCls}-${props.mode}`, { + [props.className]: !!props.className, + [this.getOpenClassName()]: isOpen, + [this.getActiveClassName()]: props.active || (isOpen && !isInlineMode), + [this.getDisabledClassName()]: props.disabled, + [this.getSelectedClassName()]: this.isChildrenSelected(), + }) + + if (!this._menuId) { + if (props.eventKey) { + this._menuId = `${props.eventKey}$Menu` + } else { + this._menuId = `$__$${++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 = {} + if (isInlineMode) { + style.paddingLeft = props.inlineIndent * props.level + } + const title = ( +
    + {props.title} + +
    + ) + const children = this.renderChildren(props.children) + + const getPopupContainer = props.parentMenu.isRootMenu + ? props.parentMenu.props.getPopupContainer : triggerNode => triggerNode.parentNode + const popupPlacement = popupPlacementMap[props.mode] + const popupClassName = props.mode === 'inline' ? '' : props.popupClassName + return ( +
  • + {isInlineMode && title} + {isInlineMode && children} + {!isInlineMode && ( + + {title} + + )} +
  • + ) + }, +}) + +SubMenu.isSubMenu = 1 + +export default SubMenu diff --git a/components/menu/src/SubPopupMenu.js b/components/menu/src/SubPopupMenu.js new file mode 100644 index 000000000..e730da3f7 --- /dev/null +++ b/components/menu/src/SubPopupMenu.js @@ -0,0 +1,99 @@ +import React from 'react' +import PropTypes from 'prop-types' +import createReactClass from 'create-react-class' +import Animate from 'rc-animate' +import MenuMixin from './MenuMixin' + +const SubPopupMenu = createReactClass({ + displayName: 'SubPopupMenu', + + propTypes: { + onSelect: PropTypes.func, + onClick: PropTypes.func, + onDeselect: PropTypes.func, + onOpenChange: PropTypes.func, + onDestroy: PropTypes.func, + openTransitionName: PropTypes.string, + openAnimation: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + openKeys: PropTypes.arrayOf(PropTypes.string), + visible: PropTypes.bool, + children: PropTypes.any, + }, + + mixins: [MenuMixin], + + onDeselect (selectInfo) { + this.props.onDeselect(selectInfo) + }, + + onSelect (selectInfo) { + this.props.onSelect(selectInfo) + }, + + onClick (e) { + this.props.onClick(e) + }, + + onOpenChange (e) { + this.props.onOpenChange(e) + }, + + onDestroy (key) { + this.props.onDestroy(key) + }, + + getOpenTransitionName () { + return this.props.openTransitionName + }, + + renderMenuItem (c, i, subIndex) { + if (!c) { + return null + } + const props = this.props + const extraProps = { + openKeys: props.openKeys, + selectedKeys: props.selectedKeys, + triggerSubMenuAction: props.triggerSubMenuAction, + } + return this.renderCommonMenuItem(c, i, subIndex, extraProps) + }, + + render () { + const props = { ...this.props } + + const haveRendered = this.haveRendered + this.haveRendered = true + + this.haveOpened = this.haveOpened || props.visible || props.forceSubMenuRender + if (!this.haveOpened) { + return null + } + + const transitionAppear = !(!haveRendered && props.visible && props.mode === 'inline') + + props.className += ` ${props.prefixCls}-sub` + const animProps = {} + if (props.openTransitionName) { + animProps.transitionName = props.openTransitionName + } else if (typeof props.openAnimation === 'object') { + animProps.animation = { ...props.openAnimation } + if (!transitionAppear) { + delete animProps.animation.appear + } + } + + return ( + + {this.renderRoot(props)} + + ) + }, +}) + +export default SubPopupMenu diff --git a/components/menu/src/index.js b/components/menu/src/index.js new file mode 100644 index 000000000..8aef44de9 --- /dev/null +++ b/components/menu/src/index.js @@ -0,0 +1,9 @@ +import Menu from './Menu' +import SubMenu from './SubMenu' +import MenuItem from './MenuItem' +import MenuItemGroup from './MenuItemGroup' +import Divider from './Divider' + +export { SubMenu, MenuItem as Item, MenuItem, MenuItemGroup, MenuItemGroup as ItemGroup, Divider } + +export default Menu diff --git a/components/menu/src/placements.js b/components/menu/src/placements.js new file mode 100644 index 000000000..c94653e48 --- /dev/null +++ b/components/menu/src/placements.js @@ -0,0 +1,29 @@ +const autoAdjustOverflow = { + adjustX: 1, + adjustY: 1, +} + +export const placements = { + topLeft: { + points: ['bl', 'tl'], + overflow: autoAdjustOverflow, + offset: [0, -7], + }, + bottomLeft: { + points: ['tl', 'bl'], + overflow: autoAdjustOverflow, + offset: [0, 7], + }, + leftTop: { + points: ['tr', 'tl'], + overflow: autoAdjustOverflow, + offset: [-4, 0], + }, + rightTop: { + points: ['tl', 'tr'], + overflow: autoAdjustOverflow, + offset: [4, 0], + }, +} + +export default placements diff --git a/components/menu/src/util.js b/components/menu/src/util.js new file mode 100644 index 000000000..d9a56c94e --- /dev/null +++ b/components/menu/src/util.js @@ -0,0 +1,46 @@ +import React from 'react' + +export function noop () { +} + +export function getKeyFromChildrenIndex (child, menuEventKey, index) { + const prefix = menuEventKey || '' + return child.key || `${prefix}item_${index}` +} + +export function loopMenuItem (children, cb) { + let index = -1 + React.Children.forEach(children, (c) => { + index++ + if (c && c.type && c.type.isMenuItemGroup) { + React.Children.forEach(c.props.children, (c2) => { + index++ + cb(c2, index) + }) + } else { + cb(c, index) + } + }) +} + +export function loopMenuItemRecusively (children, keys, ret) { + if (!children || ret.find) { + return + } + React.Children.forEach(children, (c) => { + if (ret.find) { + return + } + if (c) { + const construt = c.type + if (!construt || !(construt.isSubMenu || construt.isMenuItem || construt.isMenuItemGroup)) { + return + } + if (keys.indexOf(c.key) !== -1) { + ret.find = true + } else if (c.props.children) { + loopMenuItemRecusively(c.props.children, keys, ret) + } + } + }) +} diff --git a/package.json b/package.json index 3276e61d6..e99c19ab8 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "eslint-plugin-vue": "^3.13.0", "lodash.debounce": "^4.0.8", "omit.js": "^1.0.0", + "vue-types": "^1.0.2", "warning": "^3.0.0" } }