ant-design-vue/components/vc-menu/SubMenu.jsx

552 lines
16 KiB
Vue

import {
computed,
defineComponent,
getCurrentInstance,
inject,
onBeforeUnmount,
onMounted,
provide,
reactive,
} from 'vue';
import omit from 'omit.js';
import PropTypes from '../_util/vue-types';
import Trigger from '../vc-trigger';
import KeyCode from '../_util/KeyCode';
import SubPopupMenu from './SubPopupMenu';
import placements from './placements';
import BaseMixin from '../_util/BaseMixin';
import { getComponent, splitAttrs, findDOMNode, getSlot } from '../_util/props-util';
import { requestAnimationTimeout, cancelAnimationTimeout } from '../_util/requestAnimationTimeout';
import { noop, getMenuIdFromSubMenuEventKey, loopMenuItemRecursively } from './util';
import { getTransitionProps, Transition } from '../_util/transition';
import InjectExtraProps from './InjectExtraProps';
let guid = 0;
const popupPlacementMap = {
horizontal: 'bottomLeft',
vertical: 'rightTop',
'vertical-left': 'rightTop',
'vertical-right': 'leftTop',
};
const updateDefaultActiveFirst = (store, eventKey, defaultActiveFirst) => {
const menuId = getMenuIdFromSubMenuEventKey(eventKey);
store.defaultActiveFirst[menuId] = defaultActiveFirst;
};
let indexGuid = 0;
const SubMenu = defineComponent({
name: 'SubMenu',
mixins: [BaseMixin],
inheritAttrs: false,
isSubMenu: true,
props: {
title: PropTypes.any,
openKeys: PropTypes.array.def([]),
openChange: PropTypes.func.def(noop),
rootPrefixCls: PropTypes.string,
eventKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
multiple: PropTypes.looseBool,
isRootMenu: PropTypes.looseBool.def(false),
index: PropTypes.number,
triggerSubMenuAction: PropTypes.string,
popupClassName: PropTypes.string,
getPopupContainer: PropTypes.func,
forceSubMenuRender: PropTypes.looseBool.def(false),
openAnimation: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
disabled: PropTypes.looseBool,
subMenuOpenDelay: PropTypes.number.def(0.1),
subMenuCloseDelay: PropTypes.number.def(0.1),
level: PropTypes.number.def(1),
inlineIndent: PropTypes.number.def(24),
openTransitionName: PropTypes.string,
popupOffset: PropTypes.array,
mode: PropTypes.oneOf([
'horizontal',
'vertical',
'vertical-left',
'vertical-right',
'inline',
]).def('vertical'),
manualRef: PropTypes.func.def(noop),
builtinPlacements: PropTypes.object.def(() => ({})),
itemIcon: PropTypes.any,
expandIcon: PropTypes.any,
subMenuKey: PropTypes.string,
theme: PropTypes.string,
parentUniKeys: PropTypes.array.def(() => []),
parentUniKey: PropTypes.string,
},
isSubMenu: true,
setup(props) {
const uniKey = `sub_menu_${++indexGuid}`;
const store = inject('menuStore', () => ({}));
onMounted(() => {
store.addChildrenInfo(
uniKey,
computed(() => ({
parentUniKeys: props.parentUniKeys,
parentUniKey: props.parentUniKey,
eventKey: props.eventKey,
disabled: props.disabled,
})),
);
});
onBeforeUnmount(() => {
store.removeChildrenInfo(uniKey);
});
const isChildrenSelected = computed(() => {
return store.selectedParentUniKeys.indexOf(uniKey) !== -1;
});
const ins = getCurrentInstance();
const getEl = () => {
return ins.vnode.el;
};
provide(
'parentMenu',
reactive({
isRootMenu: computed(() => props.isRootMenu),
getPopupContainer: props.getPopupContainer,
getEl,
}),
);
return {
parentMenu: inject('parentMenu', undefined),
store,
isChildrenSelected,
childrenUniKeys: [...props.parentUniKeys, uniKey],
uniKey,
isOpen: computed(() => store.openKeys.indexOf(props.eventKey) > -1),
active: computed(() => store.activeKey[props.subMenuKey] === props.eventKey),
};
},
data() {
const props = this.$props;
const store = this.store;
const eventKey = props.eventKey;
const defaultActiveFirst = store.defaultActiveFirst;
let value = false;
if (defaultActiveFirst) {
value = defaultActiveFirst[eventKey];
}
updateDefaultActiveFirst(store, eventKey, value);
this.internalMenuId = undefined;
this.haveRendered = undefined;
this.haveOpened = undefined;
this.subMenuTitle = undefined;
return {};
},
mounted() {
this.$nextTick(() => {
this.handleUpdated();
});
},
updated() {
this.$nextTick(() => {
this.handleUpdated();
});
},
beforeUnmount() {
const { eventKey } = this;
this.__emit('destroy', eventKey);
/* istanbul ignore if */
if (this.minWidthTimeout) {
cancelAnimationTimeout(this.minWidthTimeout);
this.minWidthTimeout = null;
}
/* istanbul ignore if */
if (this.mouseenterTimeout) {
cancelAnimationTimeout(this.mouseenterTimeout);
this.mouseenterTimeout = null;
}
},
methods: {
isChildrenSelected2() {
if (this.haveOpened) return this.isChildrenSelected;
const ret = { find: false };
loopMenuItemRecursively(getSlot(this), this.store.selectedKeys, ret);
return ret.find;
},
handleUpdated() {
const { mode, manualRef } = this.$props;
// invoke customized ref to expose component to mixin
if (manualRef) {
manualRef(this);
}
if (mode !== 'horizontal' || !this.parentMenu.isRootMenu || !this.isOpen) {
return;
}
this.minWidthTimeout = requestAnimationTimeout(() => this.adjustWidth(), 0);
},
onKeyDown(e) {
const keyCode = e.keyCode;
const menu = this.menuInstance;
const { isOpen } = this;
if (keyCode === KeyCode.ENTER) {
this.onTitleClick(e);
updateDefaultActiveFirst(this.store, this.$props.eventKey, true);
return true;
}
if (keyCode === KeyCode.RIGHT) {
if (isOpen) {
menu.onKeyDown(e);
} else {
this.triggerOpenChange(true);
// need to update current menu's defaultActiveFirst value
updateDefaultActiveFirst(this.store, this.$props.eventKey, 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);
}
return undefined;
},
onPopupVisibleChange(visible) {
this.triggerOpenChange(visible, visible ? 'mouseenter' : 'mouseleave');
},
onMouseEnter(e) {
const { eventKey: key } = this.$props;
updateDefaultActiveFirst(this.store, key, false);
this.__emit('mouseenter', {
key,
domEvent: e,
});
},
onMouseLeave(e) {
const { eventKey } = this.$props;
this.__emit('mouseleave', {
key: eventKey,
domEvent: e,
});
},
onTitleMouseEnter(domEvent) {
const { eventKey: key } = this.$props;
this.__emit('itemHover', {
key,
hover: true,
});
this.__emit('titleMouseenter', {
key,
domEvent,
});
},
onTitleMouseLeave(e) {
const { eventKey } = this.$props;
this.__emit('itemHover', {
key: eventKey,
hover: false,
});
this.__emit('titleMouseleave', {
key: eventKey,
domEvent: e,
});
},
onTitleClick(e) {
const { triggerSubMenuAction, eventKey } = this.$props;
this.__emit('titleClick', {
key: eventKey,
domEvent: e,
});
if (triggerSubMenuAction === 'hover') {
return;
}
this.triggerOpenChange(!this.isOpen, 'click');
updateDefaultActiveFirst(this.store, eventKey, false);
},
onSubMenuClick(info) {
this.__emit('click', this.addKeyPath(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) {
// children menu instance
this.menuInstance = c;
},
addKeyPath(info) {
return {
...info,
keyPath: (info.keyPath || []).concat(this.$props.eventKey),
};
},
triggerOpenChange(open, type) {
const key = this.$props.eventKey;
const openChange = () => {
this.__emit('openChange', {
key,
item: this.$props,
trigger: type,
open,
});
};
if (type === 'mouseenter') {
// make sure mouseenter happen after other menu item's mouseleave
this.mouseenterTimeout = requestAnimationTimeout(() => {
openChange();
}, 0);
} else {
openChange();
}
},
adjustWidth() {
/* istanbul ignore if */
if (!this.subMenuTitle || !this.menuInstance) {
return;
}
const popupMenu = findDOMNode(this.menuInstance);
if (popupMenu.offsetWidth >= this.subMenuTitle.offsetWidth) {
return;
}
/* istanbul ignore next */
popupMenu.style.minWidth = `${this.subMenuTitle.offsetWidth}px`;
},
saveSubMenuTitle(subMenuTitle) {
this.subMenuTitle = subMenuTitle;
},
renderChildren() {
const props = { ...this.$props, ...this.$attrs };
const subPopupMenuProps = {
mode: props.mode === 'horizontal' ? 'vertical' : props.mode,
visible: this.isOpen,
level: props.level + 1,
inlineIndent: props.inlineIndent,
focusable: false,
eventKey: `${props.eventKey}-menu-`,
openKeys: props.openKeys,
openTransitionName: props.openTransitionName,
openAnimation: props.openAnimation,
subMenuOpenDelay: props.subMenuOpenDelay,
subMenuCloseDelay: props.subMenuCloseDelay,
forceSubMenuRender: props.forceSubMenuRender,
triggerSubMenuAction: props.triggerSubMenuAction,
builtinPlacements: props.builtinPlacements,
multiple: props.multiple,
prefixCls: props.rootPrefixCls,
manualRef: this.saveMenuInstance,
itemIcon: getComponent(this, 'itemIcon'),
expandIcon: getComponent(this, 'expandIcon'),
onClick: this.onSubMenuClick,
onSelect: props.onSelect || noop,
onDeselect: props.onDeselect || noop,
onOpenChange: props.onOpenChange || noop,
id: this.internalMenuId,
parentUniKeys: this.childrenUniKeys,
parentUniKey: this.uniKey,
};
const haveRendered = this.haveRendered;
this.haveRendered = true;
this.haveOpened =
this.haveOpened || subPopupMenuProps.visible || subPopupMenuProps.forceSubMenuRender;
// never rendered not planning to, don't render
if (!this.haveOpened) {
return <div />;
}
// 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 transitionAppear =
haveRendered || !subPopupMenuProps.visible || !subPopupMenuProps.mode === 'inline';
subPopupMenuProps.class = ` ${subPopupMenuProps.prefixCls}-sub`;
let transitionProps = { appear: transitionAppear, css: false };
if (subPopupMenuProps.openTransitionName) {
transitionProps = getTransitionProps(subPopupMenuProps.openTransitionName, {
appear: transitionAppear,
});
} else if (typeof subPopupMenuProps.openAnimation === 'object') {
transitionProps = { ...transitionProps, ...(subPopupMenuProps.openAnimation || {}) };
if (!transitionAppear) {
transitionProps.appear = false;
}
} else if (typeof subPopupMenuProps.openAnimation === 'string') {
transitionProps = getTransitionProps(subPopupMenuProps.openAnimation, {
appear: transitionAppear,
});
}
return (
<Transition {...transitionProps}>
<SubPopupMenu v-show={this.isOpen} {...subPopupMenuProps} v-slots={this.$slots} />
</Transition>
);
},
},
render() {
const props = { ...this.$props, ...this.$attrs };
const { onEvents } = splitAttrs(props);
const isOpen = this.isOpen;
const prefixCls = this.getPrefixCls();
const isInlineMode = props.mode === 'inline';
const children = this.renderChildren();
const className = {
[prefixCls]: true,
[`${prefixCls}-${props.mode}`]: true,
[props.class]: !!props.class,
[this.getOpenClassName()]: isOpen,
[this.getActiveClassName()]: this.active || (isOpen && !isInlineMode),
[this.getDisabledClassName()]: props.disabled,
[this.getSelectedClassName()]: this.isChildrenSelected || this.isChildrenSelected2(),
};
if (!this.internalMenuId) {
if (props.eventKey) {
this.internalMenuId = `${props.eventKey}$Menu`;
} else {
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 = {};
if (isInlineMode) {
style.paddingLeft = `${props.inlineIndent * props.level}px`;
}
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 (isOpen) {
ariaOwns = {
'aria-owns': this.internalMenuId,
};
}
const titleProps = {
'aria-expanded': isOpen,
...ariaOwns,
'aria-haspopup': 'true',
title: typeof props.title === 'string' ? props.title : undefined,
...titleMouseEvents,
...titleClickEvents,
style,
class: `${prefixCls}-title`,
ref: this.saveSubMenuTitle,
};
// expand custom icon should NOT be displayed in menu with horizontal mode.
let icon = null;
if (props.mode !== 'horizontal') {
icon = getComponent(this, 'expandIcon', props);
}
const title = (
<div {...titleProps}>
{getComponent(this, 'title')}
{icon || <i class={`${prefixCls}-arrow`} />}
</div>
);
const getPopupContainer = this.parentMenu.isRootMenu
? this.parentMenu.getPopupContainer
: triggerNode => triggerNode.parentNode;
const popupPlacement = popupPlacementMap[props.mode];
const popupAlign = props.popupOffset ? { offset: props.popupOffset } : {};
let popupClassName = props.mode === 'inline' ? '' : props.popupClassName || '';
popupClassName = `${prefixCls}-popup ${popupClassName}`;
const liProps = {
...omit(onEvents, ['onClick']),
...mouseEvents,
class: className,
style: props.style,
};
return (
<li {...liProps} role="menuitem">
{isInlineMode && title}
{isInlineMode && children}
{!isInlineMode && (
<Trigger
prefixCls={prefixCls}
popupClassName={popupClassName}
getPopupContainer={getPopupContainer}
builtinPlacements={placements}
builtinPlacements={Object.assign({}, placements, props.builtinPlacements)}
popupPlacement={popupPlacement}
popupVisible={isOpen}
popupAlign={popupAlign}
action={props.disabled ? [] : [props.triggerSubMenuAction]}
mouseEnterDelay={props.subMenuOpenDelay}
mouseLeaveDelay={props.subMenuCloseDelay}
onPopupVisibleChange={this.onPopupVisibleChange}
forceRender={props.forceSubMenuRender}
// popupTransitionName='rc-menu-open-slide-up'
// popupAnimation={transitionProps}
popup={children}
>
{title}
</Trigger>
)}
</li>
);
},
});
export default InjectExtraProps(SubMenu);