src/menu.vue
<script type="text/jsx"> import emitter from 'element-ui/src/mixins/emitter'; import Migrating from 'element-ui/src/mixins/migrating'; import Menubar from 'element-ui/src/utils/menu/aria-menubar'; import { addClass, removeClass, hasClass } from 'element-ui/src/utils/dom'; export default { name: 'ElMenu', render (h) { const component = ( <ul role="menubar" key={ +this.collapse } style={{ backgroundColor: this.backgroundColor || '' }} class={{ 'el-menu--horizontal': this.mode === 'horizontal', 'el-menu--collapse': this.collapse, "el-menu": true }} > { this.$slots.default } </ul> ); if (this.collapseTransition) { return ( <el-menu-collapse-transition> { component } </el-menu-collapse-transition> ); } else { return component; } }, componentName: 'ElMenu', mixins: [emitter, Migrating], // 向子孙级注入自身 provide() { return { rootMenu: this }; }, components: { // 折叠动画组件 'el-menu-collapse-transition': { // 一个 函数化组件 就像这样:标记组件为 functional, 这意味它是无状态(没有 data),无实例(没有 this 上下文)。 functional: true, render(createElement, context) { const data = { props: { mode: 'out-in' }, on: { // 进入前 beforeEnter(el) { el.style.opacity = 0.2; }, // 进入中 enter(el) { addClass(el, 'el-opacity-transition'); el.style.opacity = 1; }, // 进入后 afterEnter(el) { removeClass(el, 'el-opacity-transition'); el.style.opacity = ''; }, // 离开前 beforeLeave(el) { if (!el.dataset) el.dataset = {}; if (hasClass(el, 'el-menu--collapse')) { removeClass(el, 'el-menu--collapse'); // 记录状态 el.dataset.oldOverflow = el.style.overflow; el.dataset.scrollWidth = el.clientWidth; addClass(el, 'el-menu--collapse'); } else { addClass(el, 'el-menu--collapse'); el.dataset.oldOverflow = el.style.overflow; el.dataset.scrollWidth = el.clientWidth; removeClass(el, 'el-menu--collapse'); } el.style.width = el.scrollWidth + 'px'; el.style.overflow = 'hidden'; }, leave(el) { addClass(el, 'horizontal-collapse-transition'); el.style.width = el.dataset.scrollWidth + 'px'; } } }; return createElement('transition', data, context.children); } } }, props: { // mode 模式 string horizontal / vertical vertical mode: { type: String, default: 'vertical' }, // 当前激活菜单的 index defaultActive: { type: String, default: '' }, // 当前打开的 sub-menu 的 index 的数组 defaultOpeneds: Array, // 是否只保持一个子菜单的展开 uniqueOpened: Boolean, // 是否使用 vue-router 的模式,启用该模式会在激活导航时以 index 作为 path 进行路由跳转 router: Boolean, // 子菜单打开的触发方式(只在 mode 为 horizontal 时有效) menuTrigger: { type: String, default: 'hover' }, // 是否水平折叠收起菜单(仅在 mode 为 vertical 时可用 collapse: Boolean, // 菜单的背景色(仅支持 hex 格式) backgroundColor: String, // 菜单的文字颜色(仅支持 hex 格式) textColor: String, // 当前激活菜单的文字颜色(仅支持 hex 格式) activeTextColor: String, // 是否开启折叠动画 collapseTransition: { type: Boolean, default: true } }, data() { return { activeIndex: this.defaultActive,//当前激活的菜单index openedMenus: (this.defaultOpeneds && !this.collapse) ? this.defaultOpeneds.slice(0) : [],//默认展开的菜单 items: {}, submenus: {} }; }, computed: { // 菜单hover时颜色 hoverBackground() { return this.backgroundColor ? this.mixColor(this.backgroundColor, 0.2) : ''; }, // 是否展示hover状态下的下拉框 isMenuPopup() { // 如果是水平或者垂直方向并且折叠返回true return this.mode === 'horizontal' || (this.mode === 'vertical' && this.collapse); } }, watch: { // 监听激活菜单 defaultActive(value){ if(!this.items[value]){ this.activeIndex = null } this.updateActiveIndex(value) }, defaultOpeneds(value) { if (!this.collapse) { this.openedMenus = value; } }, collapse(value) { if (value) this.openedMenus = []; this.broadcast('ElSubmenu', 'toggle-collapse', value); } }, methods: { // 更新激活的菜单index updateActiveIndex(val) { const item = this.items[val] || this.items[this.activeIndex] || this.items[this.defaultActive]; if (item) { this.activeIndex = item.index; this.initOpenedMenu(); } else { this.activeIndex = null; } }, getMigratingConfig() { return { props: { 'theme': 'theme is removed.' } }; }, // 获取颜色通道 getColorChannels(color) { // 去掉颜色前面#号 color = color.replace('#', ''); // 如果是3位简写 if (/^[0-9a-fA-F]{3}$/.test(color)) { // 转成数组 color = color.split(''); for (let i = 2; i >= 0; i--) { // 用法:array.splice(start,deleteCount,item...) // 解释:splice方法从array中移除一个或多个数组,并用新的item替换它们。参数start是从数组array中移除元素的开始位置。 // 参数deleteCount是要移除的元素的个数。 color.splice(i, 0, color[i]); } // 转变为6位长度颜色值 color = color.join(''); } if (/^[0-9a-fA-F]{6}$/.test(color)) { return { // 转变为16进制数值 red: parseInt(color.slice(0, 2), 16), green: parseInt(color.slice(2, 4), 16), blue: parseInt(color.slice(4, 6), 16) }; } else { // 否则为白色 return { red: 255, green: 255, blue: 255 }; } }, // 获取混合后的颜色 mixColor(color, percent) { let { red, green, blue } = this.getColorChannels(color); if (percent > 0) { // shade given color red *= 1 - percent; green *= 1 - percent; blue *= 1 - percent; } else { // tint given color red += (255 - red) * percent; green += (255 - green) * percent; blue += (255 - blue) * percent; } return `rgb(${ Math.round(red) }, ${ Math.round(green) }, ${ Math.round(blue) })`; }, // 添加菜单 addItem(item) { this.$set(this.items, item.index, item); }, // 移除带单 removeItem(item) { delete this.items[item.index]; }, // 添加子菜单 addSubmenu(item) { this.$set(this.submenus, item.index, item); }, // 移除子菜单 removeSubmenu(item) { delete this.submenus[item.index]; }, // 打开菜单 openMenu(index, indexPath) { let openedMenus = this.openedMenus; if (openedMenus.indexOf(index) !== -1) return; // 将不在该菜单路径下的其余菜单收起 // collapse all menu that are not under current menu item // 如果设置了只保留一个子菜单展开 if (this.uniqueOpened) { this.openedMenus = openedMenus.filter(index => { return indexPath.indexOf(index) !== -1; }); } this.openedMenus.push(index); }, // 关闭菜单 closeMenu(index) { const i = this.openedMenus.indexOf(index); if (i !== -1) { this.openedMenus.splice(i, 1); } }, // 子菜单点击事件 handleSubmenuClick(submenu) { const { index, indexPath } = submenu; let isOpened = this.openedMenus.indexOf(index) !== -1; // 如果打开就关闭,否则就打开 if (isOpened) { this.closeMenu(index); this.$emit('close', index, indexPath); } else { this.openMenu(index, indexPath); this.$emit('open', index, indexPath); } }, handleItemClick(item) { const { index, indexPath } = item; const oldActiveIndex = this.activeIndex; const hasIndex = item.index !== null; if (hasIndex) { // 点击某个菜单,设置为激活状态 this.activeIndex = item.index; } // 触发select事件 this.$emit('select', index, indexPath, item); // 如果是水平或者折叠,清空打开的菜单 if (this.mode === 'horizontal' || this.collapse) { this.openedMenus = []; } // 如果设置了vue-router 的模式并且有index if (this.router && hasIndex) { this.routeToItem(item, (error) => { this.activeIndex = oldActiveIndex; if (error) console.error(error); }); } }, // 初始化展开菜单 // initialize opened menu initOpenedMenu() { const index = this.activeIndex; const activeItem = this.items[index]; if (!activeItem || this.mode === 'horizontal' || this.collapse) return; // 保存路由跳转路径 let indexPath = activeItem.indexPath; // 展开该菜单项的路径上所有子菜单 // expand all submenus of the menu item indexPath.forEach(index => { let submenu = this.submenus[index]; submenu && this.openMenu(index, submenu.indexPath); }); }, // 跳转 routeToItem(item, onError) { let route = item.route || item.index; try { this.$router.push(route, () => {}, onError); } catch (e) { console.error(e); } }, // sub-menu 展开的回调 index: 打开的 sub-menu 的 index, indexPath: 打开的 sub-menu 的 index path open(index) { const { indexPath } = this.submenus[index.toString()]; indexPath.forEach(i => this.openMenu(i, indexPath)); }, // sub-menu 收起的回调 index: 收起的 sub-menu 的 index, indexPath: 收起的 sub-menu 的 index path close(index) { this.closeMenu(index); } }, mounted() { this.initOpenedMenu(); // 接收item-click事件,handleItemClick调用 this.$on('item-click', this.handleItemClick); // 接收submenu-click事件,触发handleSubmenuClick调用 this.$on('submenu-click', this.handleSubmenuClick); if (this.mode === 'horizontal') { new Menubar(this.$el); // eslint-disable-line } this.$watch('items', this.updateActiveIndex); } }; </script>
src/submenu.vue
<script> import ElCollapseTransition from 'element-ui/src/transitions/collapse-transition'; import menuMixin from './menu-mixin'; import Emitter from 'element-ui/src/mixins/emitter'; import Popper from 'element-ui/src/utils/vue-popper'; const poperMixins = { props: { transformOrigin: { type: [Boolean, String], default: false }, offset: Popper.props.offset, boundariesPadding: Popper.props.boundariesPadding, popperOptions: Popper.props.popperOptions }, data: Popper.data, methods: Popper.methods, beforeDestroy: Popper.beforeDestroy, deactivated: Popper.deactivated // keep-alive组件停用时调用 }; export default { name: 'ElSubmenu', componentName: 'ElSubmenu', mixins: [menuMixin, Emitter, poperMixins], components: { ElCollapseTransition }, props: { // 唯一标志 string/null — null index: { type: String, required: true }, // 展开 sub-menu 的延时 number — 300 showTimeout: { type: Number, default: 300 }, // 收起 sub-menu 的延时 number — 300 hideTimeout: { type: Number, default: 300 }, // 弹出菜单的自定义类名 popperClass: String, // 是否禁用 disabled: Boolean, // 是否将弹出菜单插入至 body 元素。在菜单的定位出现问题时,可尝试修改该属性 boolean — 一级子菜单:true / 非一级子菜单:false popperAppendToBody: { type: Boolean, default: undefined } }, data() { return { popperJS: null, timeout: null, items: {}, submenus: {}, mouseInChild: false }; }, watch: { // 是否打开 opened(val) { if (this.isMenuPopup) { this.$nextTick(_ => { this.updatePopper(); }); } } }, computed: { // popper option appendToBody() { return this.popperAppendToBody === undefined ? this.isFirstLevel : this.popperAppendToBody; }, menuTransitionName() { return this.rootMenu.collapse ? 'el-zoom-in-left' : 'el-zoom-in-top'; }, // 打开 opened() { return this.rootMenu.openedMenus.indexOf(this.index) > -1; }, active() { let isActive = false; const submenus = this.submenus; const items = this.items; Object.keys(items).forEach(index => { if (items[index].active) { isActive = true; } }); Object.keys(submenus).forEach(index => { if (submenus[index].active) { isActive = true; } }); return isActive; }, // 鼠标移入的背景色 hoverBackground() { return this.rootMenu.hoverBackground; }, // 菜单的背景色(仅支持 hex 格式) backgroundColor() { return this.rootMenu.backgroundColor || ''; }, // 当前激活菜单的文字颜色(仅支持 hex 格式) activeTextColor() { return this.rootMenu.activeTextColor || ''; }, // 菜单的文字颜色(仅支持 hex 格式) textColor() { return this.rootMenu.textColor || ''; }, // 模式 mode() { return this.rootMenu.mode; }, // 是否展示下拉框 isMenuPopup() { return this.rootMenu.isMenuPopup; }, // title样式 titleStyle() { if (this.mode !== 'horizontal') { return { color: this.textColor }; } return { borderBottomColor: this.active ? (this.rootMenu.activeTextColor ? this.activeTextColor : '') : 'transparent', color: this.active ? this.activeTextColor : this.textColor }; }, // 是否是第一级 isFirstLevel() { let isFirstLevel = true; let parent = this.$parent; while (parent && parent !== this.rootMenu) { if (['ElSubmenu', 'ElMenuItemGroup'].indexOf(parent.$options.componentName) > -1) { isFirstLevel = false; break; } else { parent = parent.$parent; } } return isFirstLevel; } }, methods: { // 开关折叠 handleCollapseToggle(value) { // 如果为true if (value) { this.initPopper(); } else { this.doDestroy(); } }, addItem(item) { this.$set(this.items, item.index, item); }, removeItem(item) { delete this.items[item.index]; }, addSubmenu(item) { this.$set(this.submenus, item.index, item); }, removeSubmenu(item) { delete this.submenus[item.index]; }, // 点击子菜单 handleClick() { const { rootMenu, disabled } = this; if ( (rootMenu.menuTrigger === 'hover' && rootMenu.mode === 'horizontal') || (rootMenu.collapse && rootMenu.mode === 'vertical') || disabled ) { return; } this.dispatch('ElMenu', 'submenu-click', this); }, // 鼠标移入 handleMouseenter(event, showTimeout = this.showTimeout) { if (!('ActiveXObject' in window) && event.type === 'focus' && !event.relatedTarget) { return; } const { rootMenu, disabled } = this; if ( (rootMenu.menuTrigger === 'click' && rootMenu.mode === 'horizontal') || (!rootMenu.collapse && rootMenu.mode === 'vertical') || disabled ) { return; } // 寻找父级,在父组件触发 this.dispatch('ElSubmenu', 'mouse-enter-child'); clearTimeout(this.timeout); this.timeout = setTimeout(() => { this.rootMenu.openMenu(this.index, this.indexPath); }, showTimeout); }, // 鼠标移出 handleMouseleave() { const {rootMenu} = this; if ( (rootMenu.menuTrigger === 'click' && rootMenu.mode === 'horizontal') || (!rootMenu.collapse && rootMenu.mode === 'vertical') ) { return; } this.dispatch('ElSubmenu', 'mouse-leave-child'); clearTimeout(this.timeout); this.timeout = setTimeout(() => { !this.mouseInChild && this.rootMenu.closeMenu(this.index); }, this.hideTimeout); }, // 鼠标移入子菜单,背景色设置为父组件鼠标移入背景色 handleTitleMouseenter() { if (this.mode === 'horizontal' && !this.rootMenu.backgroundColor) return; const title = this.$refs['submenu-title']; title && (title.style.backgroundColor = this.rootMenu.hoverBackground); }, // 同上 handleTitleMouseleave() { if (this.mode === 'horizontal' && !this.rootMenu.backgroundColor) return; const title = this.$refs['submenu-title']; title && (title.style.backgroundColor = this.rootMenu.backgroundColor || ''); }, // 更新位置 updatePlacement() { // 如果是水平方向且是一级为底部,否则为右侧 this.currentPlacement = this.mode === 'horizontal' && this.isFirstLevel ? 'bottom-start' : 'right-start'; }, // 初始化poper initPopper() { this.referenceElm = this.$el; this.popperElm = this.$refs.menu; this.updatePlacement(); } }, created() { // 接收toggle-collapse订阅,触发handleCollapseToggle this.$on('toggle-collapse', this.handleCollapseToggle); // 接收mouse-enter-child订阅,鼠标进入 this.$on('mouse-enter-child', () => { this.mouseInChild = true; clearTimeout(this.timeout); }); // 鼠标离开 this.$on('mouse-leave-child', () => { this.mouseInChild = false; clearTimeout(this.timeout); }); }, mounted() { // 初始化,添加 this.parentMenu.addSubmenu(this); this.rootMenu.addSubmenu(this); this.initPopper(); }, // 销毁前 beforeDestroy() { // 移除 this.parentMenu.removeSubmenu(this); this.rootMenu.removeSubmenu(this); }, render(h) { const { active, opened, paddingStyle, titleStyle, backgroundColor, rootMenu, currentPlacement, menuTransitionName, mode, disabled, popperClass, $slots, isFirstLevel } = this; const popupMenu = ( <transition name={menuTransitionName}> <div ref="menu" v-show={opened} class={[`el-menu--${mode}`, popperClass]} on-mouseenter={($event) => this.handleMouseenter($event, 100)} on-mouseleave={this.handleMouseleave} on-focus={($event) => this.handleMouseenter($event, 100)}> <ul role="menu" class={['el-menu el-menu--popup', `el-menu--popup-${currentPlacement}`]} style={{ backgroundColor: rootMenu.backgroundColor || '' }}> {$slots.default} </ul> </div> </transition> ); const inlineMenu = ( <el-collapse-transition> <ul role="menu" class="el-menu el-menu--inline" v-show={opened} style={{ backgroundColor: rootMenu.backgroundColor || '' }}> {$slots.default} </ul> </el-collapse-transition> ); const submenuTitleIcon = ( rootMenu.mode === 'horizontal' && isFirstLevel || rootMenu.mode === 'vertical' && !rootMenu.collapse ) ? 'el-icon-arrow-down' : 'el-icon-arrow-right'; return ( <li class={{ 'el-submenu': true, 'is-active': active, 'is-opened': opened, 'is-disabled': disabled }} role="menuitem" aria-haspopup="true" aria-expanded={opened} on-mouseenter={this.handleMouseenter} on-mouseleave={this.handleMouseleave} on-focus={this.handleMouseenter} > <div class="el-submenu__title" ref="submenu-title" on-click={this.handleClick} on-mouseenter={this.handleTitleMouseenter} on-mouseleave={this.handleTitleMouseleave} style={[paddingStyle, titleStyle, { backgroundColor }]} > {$slots.title} <i class={[ 'el-submenu__icon-arrow', submenuTitleIcon ]}></i> </div> {this.isMenuPopup ? popupMenu : inlineMenu} </li> ); } }; </script>
src/menu-item-group
<template> <li class="el-menu-item-group"> <div class="el-menu-item-group__title" :style="{paddingLeft: levelPadding + 'px'}"> <template v-if="!$slots.title">{{title}}</template> <slot v-else name="title"></slot> </div> <ul> <slot></slot> </ul> </li> </template> <script> export default { name: 'ElMenuItemGroup', componentName: 'ElMenuItemGroup', inject: ['rootMenu'], props: { title: { type: String } }, data() { return { paddingLeft: 20 }; }, computed: { levelPadding() { let padding = 20; let parent = this.$parent; if (this.rootMenu.collapse) return 20; while (parent && parent.$options.componentName !== 'ElMenu') { // 每层submenu + 20 if (parent.$options.componentName === 'ElSubmenu') { padding += 20; } parent = parent.$parent; } return padding; } } }; </script>
src/menu-item.vue
<template> <li class="el-menu-item" role="menuitem" tabindex="-1" :style="[paddingStyle, itemStyle, { backgroundColor }]" :class="{ 'is-active': active, 'is-disabled': disabled }" @click="handleClick" @mouseenter="onMouseEnter" @focus="onMouseEnter" @blur="onMouseLeave" @mouseleave="onMouseLeave" > <el-tooltip v-if="parentMenu.$options.componentName === 'ElMenu' && rootMenu.collapse && $slots.title" effect="dark" placement="right"> <div slot="content"><slot name="title"></slot></div> <div style="position: absolute;left: 0;top: 0;height: 100%; 100%;display: inline-block;box-sizing: border-box;padding: 0 20px;"> <slot></slot> </div> </el-tooltip> <template v-else> <slot></slot> <slot name="title"></slot> </template> </li> </template> <script> import Menu from './menu-mixin'; import ElTooltip from 'element-ui/packages/tooltip'; import Emitter from 'element-ui/src/mixins/emitter'; export default { name: 'ElMenuItem', componentName: 'ElMenuItem', mixins: [Menu, Emitter], components: { ElTooltip }, props: { index: { default: null, validator: val => typeof val === 'string' || val === null }, route: [String, Object], disabled: Boolean }, computed: { // 是否激活 active() { return this.index === this.rootMenu.activeIndex; }, // hove背景色 hoverBackground() { return this.rootMenu.hoverBackground; }, // 菜单背景色 backgroundColor() { return this.rootMenu.backgroundColor || ''; }, // 激活文字背景色 activeTextColor() { return this.rootMenu.activeTextColor || ''; }, // 文字颜色 textColor() { return this.rootMenu.textColor || ''; }, // 模式 mode() { return this.rootMenu.mode; }, // item样式 itemStyle() { const style = { color: this.active ? this.activeTextColor : this.textColor }; if (this.mode === 'horizontal' && !this.isNested) { style.borderBottomColor = this.active ? (this.rootMenu.activeTextColor ? this.activeTextColor : '') : 'transparent'; } return style; }, isNested() { return this.parentMenu !== this.rootMenu; } }, methods: { // 鼠标移入 onMouseEnter() { if (this.mode === 'horizontal' && !this.rootMenu.backgroundColor) return; this.$el.style.backgroundColor = this.hoverBackground; }, // 鼠标移出 onMouseLeave() { if (this.mode === 'horizontal' && !this.rootMenu.backgroundColor) return; this.$el.style.backgroundColor = this.backgroundColor; }, // 点击 handleClick() { if (!this.disabled) { this.dispatch('ElMenu', 'item-click', this); this.$emit('click', this); } } }, mounted() { this.parentMenu.addItem(this); this.rootMenu.addItem(this); }, beforeDestroy() { this.parentMenu.removeItem(this); this.rootMenu.removeItem(this); } }; </script>
src/menu-mixin.js
// mixis混入
export default {
// 接收
inject: ['rootMenu'],
computed: {
// 路径
indexPath () {
const path = [this.index];
let parent = this.$parent;
while (parent.$options.componentName !== 'ElMenu') {
if (parent.index) {
path.unshift(parent.index);
}
parent = parent.$parent;
}
return path;
},
// 获取elMenu或者elSubmenu
parentMenu () {
let parent = this.$parent;
while (
parent &&
['ElMenu', 'ElSubmenu'].indexOf(parent.$options.componentName) === -1
) {
parent = parent.$parent;
}
return parent;
},
// 样式
paddingStyle () {
// 不是垂直返回
if (this.rootMenu.mode !== 'vertical') return {};
let padding = 20;
let parent = this.$parent;
if (this.rootMenu.collapse) {
padding = 20;
} else {
while (parent && parent.$options.componentName !== 'ElMenu') {
// 每次遇到ElSubmenu增加20
if (parent.$options.componentName === 'ElSubmenu') {
padding += 20;
}
parent = parent.$parent;
}
}
return { paddingLeft: padding + 'px' };
}
}
};