mirror of
https://github.com/element-plus/element-plus.git
synced 2026-03-13 07:51:17 +08:00
* refactor(components): popper composables - Refactor popper composables * updates * updates for tooltip * Updates for popper. TODO: fix controlled tooltip animation * Fix controlled mode popper animation issue * Add new feature for customizing tooltip theme * Fix popover and popconfirm error * - Add Collection component for wrapping a collection of component - Add FocusTrap component for trap focus for popups - Add RovingFocus component for roving focus component type - Adjust dropdown component based on these newly added components - Add popper-trigger component for placing the trigger - TODO: Finish current dropdown component, and all component's tests plus documents * Refactor popper * Complete organizing popper * Almost finish dropdown * Update popper tests * update only-child test * Finish focus trap component test * Finish tooltip content test * Finish tooltip trigger tests * Finish tooltip tests * finish tests for Collection and RovingFocusGroup * Fix test cases for timeselect & select & popover * Fix popover, popconfirm, menu bug and test cases * Fix select-v2 test error caused by updating popper * Fix date-picker test issue for updating popper * fix test cases * Fix eslint * Rebase dev & fix tests * Remove unused code
409 lines
11 KiB
TypeScript
409 lines
11 KiB
TypeScript
import {
|
|
defineComponent,
|
|
getCurrentInstance,
|
|
watch,
|
|
computed,
|
|
ref,
|
|
provide,
|
|
onMounted,
|
|
h,
|
|
withDirectives,
|
|
reactive,
|
|
nextTick,
|
|
} from 'vue'
|
|
import { Resize } from '@element-plus/directives'
|
|
import ElIcon from '@element-plus/components/icon'
|
|
import { More } from '@element-plus/icons-vue'
|
|
import Menubar from '@element-plus/utils/menu/menu-bar'
|
|
import { buildProps, definePropType, mutable } from '@element-plus/utils/props'
|
|
import { isString, isObject } from '@element-plus/utils/util'
|
|
import ElMenuCollapseTransition from './menu-collapse-transition.vue'
|
|
import ElSubMenu from './sub-menu'
|
|
import { useMenuCssVar } from './use-menu-css-var'
|
|
|
|
import type { MenuItemClicked, MenuProvider, SubMenuProvider } from './types'
|
|
import type { NavigationFailure, Router } from 'vue-router'
|
|
import type { VNode, ExtractPropTypes, VNodeNormalizedChildren } from 'vue'
|
|
|
|
export const menuProps = buildProps({
|
|
mode: {
|
|
type: String,
|
|
values: ['horizontal', 'vertical'],
|
|
default: 'vertical',
|
|
},
|
|
defaultActive: {
|
|
type: String,
|
|
default: '',
|
|
},
|
|
defaultOpeneds: {
|
|
type: definePropType<string[]>(Array),
|
|
default: () => mutable([] as const),
|
|
},
|
|
uniqueOpened: Boolean,
|
|
router: Boolean,
|
|
menuTrigger: {
|
|
type: String,
|
|
values: ['hover', 'click'],
|
|
default: 'hover',
|
|
},
|
|
collapse: Boolean,
|
|
backgroundColor: String,
|
|
textColor: String,
|
|
activeTextColor: String,
|
|
collapseTransition: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
ellipsis: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
} as const)
|
|
export type MenuProps = ExtractPropTypes<typeof menuProps>
|
|
|
|
const checkIndexPath = (indexPath: unknown): indexPath is string[] =>
|
|
Array.isArray(indexPath) && indexPath.every((path) => isString(path))
|
|
|
|
export const menuEmits = {
|
|
close: (index: string, indexPath: string[]) =>
|
|
isString(index) && checkIndexPath(indexPath),
|
|
|
|
open: (index: string, indexPath: string[]) =>
|
|
isString(index) && checkIndexPath(indexPath),
|
|
|
|
select: (
|
|
index: string,
|
|
indexPath: string[],
|
|
item: MenuItemClicked,
|
|
routerResult?: Promise<void | NavigationFailure>
|
|
) =>
|
|
isString(index) &&
|
|
checkIndexPath(indexPath) &&
|
|
isObject(item) &&
|
|
(routerResult === undefined || routerResult instanceof Promise),
|
|
}
|
|
export type MenuEmits = typeof menuEmits
|
|
|
|
export default defineComponent({
|
|
name: 'ElMenu',
|
|
|
|
props: menuProps,
|
|
emits: menuEmits,
|
|
|
|
setup(props, { emit, slots, expose }) {
|
|
const instance = getCurrentInstance()!
|
|
const router = instance.appContext.config.globalProperties.$router as Router
|
|
const menu = ref<HTMLUListElement>()
|
|
|
|
// data
|
|
const openedMenus = ref<MenuProvider['openedMenus']>(
|
|
props.defaultOpeneds && !props.collapse
|
|
? props.defaultOpeneds.slice(0)
|
|
: []
|
|
)
|
|
const activeIndex = ref<MenuProvider['activeIndex']>(props.defaultActive)
|
|
const items = ref<MenuProvider['items']>({})
|
|
const subMenus = ref<MenuProvider['subMenus']>({})
|
|
|
|
const alteredCollapse = ref(false)
|
|
|
|
// computed
|
|
const isMenuPopup = computed<MenuProvider['isMenuPopup']>(() => {
|
|
return (
|
|
props.mode === 'horizontal' ||
|
|
(props.mode === 'vertical' && props.collapse)
|
|
)
|
|
})
|
|
|
|
// methods
|
|
const initMenu = () => {
|
|
const activeItem = activeIndex.value && items.value[activeIndex.value]
|
|
if (!activeItem || props.mode === 'horizontal' || props.collapse) return
|
|
|
|
const indexPath = activeItem.indexPath
|
|
|
|
// 展开该菜单项的路径上所有子菜单
|
|
// expand all subMenus of the menu item
|
|
indexPath.forEach((index) => {
|
|
const subMenu = subMenus.value[index]
|
|
subMenu && openMenu(index, subMenu.indexPath)
|
|
})
|
|
}
|
|
|
|
const openMenu: MenuProvider['openMenu'] = (index, indexPath) => {
|
|
if (openedMenus.value.includes(index)) return
|
|
// 将不在该菜单路径下的其余菜单收起
|
|
// collapse all menu that are not under current menu item
|
|
if (props.uniqueOpened) {
|
|
openedMenus.value = openedMenus.value.filter((index: string) =>
|
|
indexPath.includes(index)
|
|
)
|
|
}
|
|
openedMenus.value.push(index)
|
|
emit('open', index, indexPath)
|
|
}
|
|
|
|
const closeMenu: MenuProvider['closeMenu'] = (index, indexPath) => {
|
|
const i = openedMenus.value.indexOf(index)
|
|
if (i !== -1) {
|
|
openedMenus.value.splice(i, 1)
|
|
}
|
|
emit('close', index, indexPath)
|
|
}
|
|
|
|
const handleSubMenuClick: MenuProvider['handleSubMenuClick'] = ({
|
|
index,
|
|
indexPath,
|
|
}) => {
|
|
const isOpened = openedMenus.value.includes(index)
|
|
|
|
if (isOpened) {
|
|
closeMenu(index, indexPath)
|
|
} else {
|
|
openMenu(index, indexPath)
|
|
}
|
|
}
|
|
|
|
const handleMenuItemClick: MenuProvider['handleMenuItemClick'] = (
|
|
menuItem
|
|
) => {
|
|
if (props.mode === 'horizontal' || props.collapse) {
|
|
openedMenus.value = []
|
|
}
|
|
|
|
const { index, indexPath } = menuItem
|
|
if (index === undefined || indexPath === undefined) return
|
|
|
|
if (props.router && router) {
|
|
const route = menuItem.route || index
|
|
const routerResult = router.push(route).then((res) => {
|
|
if (!res) activeIndex.value = index
|
|
return res
|
|
})
|
|
emit(
|
|
'select',
|
|
index,
|
|
indexPath,
|
|
{ index, indexPath, route },
|
|
routerResult
|
|
)
|
|
} else {
|
|
activeIndex.value = index
|
|
emit('select', index, indexPath, { index, indexPath })
|
|
}
|
|
}
|
|
|
|
const updateActiveIndex = (val: string) => {
|
|
const itemsInData = items.value
|
|
const item =
|
|
itemsInData[val] ||
|
|
(activeIndex.value && itemsInData[activeIndex.value]) ||
|
|
itemsInData[props.defaultActive]
|
|
|
|
if (item) {
|
|
activeIndex.value = item.index
|
|
initMenu()
|
|
} else {
|
|
// Can't find item when collapsing
|
|
// and activeIndex shouldn't be changed when 'collapse' was changed.
|
|
// Then reset 'alteredCollapse' immediately.
|
|
if (!alteredCollapse.value) {
|
|
activeIndex.value = undefined
|
|
} else {
|
|
alteredCollapse.value = false
|
|
}
|
|
}
|
|
}
|
|
const handleResize = () => {
|
|
nextTick(() => instance.proxy!.$forceUpdate())
|
|
}
|
|
|
|
watch(
|
|
() => props.defaultActive,
|
|
(currentActive) => {
|
|
if (!items.value[currentActive]) {
|
|
activeIndex.value = ''
|
|
}
|
|
updateActiveIndex(currentActive)
|
|
}
|
|
)
|
|
|
|
watch(items.value, () => initMenu())
|
|
|
|
watch(
|
|
() => props.collapse,
|
|
(value, prev) => {
|
|
if (value !== prev) {
|
|
alteredCollapse.value = true
|
|
}
|
|
if (value) openedMenus.value = []
|
|
}
|
|
)
|
|
|
|
// provide
|
|
{
|
|
const addSubMenu: MenuProvider['addSubMenu'] = (item) => {
|
|
subMenus.value[item.index] = item
|
|
}
|
|
|
|
const removeSubMenu: MenuProvider['removeSubMenu'] = (item) => {
|
|
delete subMenus.value[item.index]
|
|
}
|
|
|
|
const addMenuItem: MenuProvider['addMenuItem'] = (item) => {
|
|
items.value[item.index] = item
|
|
}
|
|
|
|
const removeMenuItem: MenuProvider['removeMenuItem'] = (item) => {
|
|
delete items.value[item.index]
|
|
}
|
|
provide<MenuProvider>(
|
|
'rootMenu',
|
|
reactive({
|
|
props,
|
|
openedMenus,
|
|
items,
|
|
subMenus,
|
|
activeIndex,
|
|
isMenuPopup,
|
|
|
|
addMenuItem,
|
|
removeMenuItem,
|
|
addSubMenu,
|
|
removeSubMenu,
|
|
openMenu,
|
|
closeMenu,
|
|
handleMenuItemClick,
|
|
handleSubMenuClick,
|
|
})
|
|
)
|
|
provide<SubMenuProvider>(`subMenu:${instance.uid}`, {
|
|
addSubMenu,
|
|
removeSubMenu,
|
|
mouseInChild: ref(false),
|
|
})
|
|
}
|
|
|
|
// lifecycle
|
|
onMounted(() => {
|
|
initMenu()
|
|
if (props.mode === 'horizontal') {
|
|
new Menubar(instance.vnode.el!)
|
|
}
|
|
})
|
|
|
|
{
|
|
const open = (index: string) => {
|
|
const { indexPath } = subMenus.value[index]
|
|
indexPath.forEach((i) => openMenu(i, indexPath))
|
|
}
|
|
expose({
|
|
open,
|
|
close: closeMenu,
|
|
handleResize,
|
|
})
|
|
}
|
|
|
|
const flattedChildren = (children: VNodeNormalizedChildren) => {
|
|
const vnodes = Array.isArray(children) ? children : [children]
|
|
const result: any[] = []
|
|
vnodes.forEach((child: any) => {
|
|
if (Array.isArray(child.children)) {
|
|
result.push(...flattedChildren(child.children))
|
|
} else {
|
|
result.push(child)
|
|
}
|
|
})
|
|
return result
|
|
}
|
|
|
|
const useVNodeResize = (vnode: VNode) =>
|
|
props.mode === 'horizontal'
|
|
? withDirectives(vnode, [[Resize, handleResize]])
|
|
: vnode
|
|
return () => {
|
|
let slot = slots.default?.() ?? []
|
|
const vShowMore: VNode[] = []
|
|
|
|
if (props.mode === 'horizontal' && menu.value) {
|
|
const items = Array.from(menu.value?.childNodes ?? []).filter(
|
|
(item) => item.nodeName !== '#text' || item.nodeValue
|
|
) as HTMLElement[]
|
|
const originalSlot = flattedChildren(slot)
|
|
const moreItemWidth = 64
|
|
const paddingLeft = parseInt(
|
|
getComputedStyle(menu.value).paddingLeft,
|
|
10
|
|
)
|
|
const paddingRight = parseInt(
|
|
getComputedStyle(menu.value).paddingRight,
|
|
10
|
|
)
|
|
const menuWidth = menu.value.clientWidth - paddingLeft - paddingRight
|
|
let calcWidth = 0
|
|
let sliceIndex = 0
|
|
items.forEach((item, index) => {
|
|
calcWidth += item.offsetWidth || 0
|
|
if (calcWidth <= menuWidth - moreItemWidth) {
|
|
sliceIndex = index + 1
|
|
}
|
|
})
|
|
const slotDefault = originalSlot.slice(0, sliceIndex)
|
|
const slotMore = originalSlot.slice(sliceIndex)
|
|
if (slotMore?.length && props.ellipsis) {
|
|
slot = slotDefault
|
|
vShowMore.push(
|
|
h(
|
|
ElSubMenu,
|
|
{
|
|
index: 'sub-menu-more',
|
|
class: 'el-sub-menu__hide-arrow',
|
|
},
|
|
{
|
|
title: () =>
|
|
h(
|
|
ElIcon,
|
|
{
|
|
class: ['el-sub-menu__icon-more'],
|
|
},
|
|
{ default: () => h(More) }
|
|
),
|
|
default: () => slotMore,
|
|
}
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
const ulStyle = useMenuCssVar(props)
|
|
|
|
const resizeMenu = (vNode: VNode) =>
|
|
props.ellipsis ? useVNodeResize(vNode) : vNode
|
|
|
|
const vMenu = resizeMenu(
|
|
h(
|
|
'ul',
|
|
{
|
|
key: String(props.collapse),
|
|
role: 'menubar',
|
|
ref: menu,
|
|
style: ulStyle.value,
|
|
class: {
|
|
'el-menu': true,
|
|
'el-menu--horizontal': props.mode === 'horizontal',
|
|
'el-menu--collapse': props.collapse,
|
|
},
|
|
},
|
|
[...slot.map((vnode) => resizeMenu(vnode)), ...vShowMore]
|
|
)
|
|
)
|
|
|
|
if (props.collapseTransition && props.mode === 'vertical') {
|
|
return h(ElMenuCollapseTransition, () => vMenu)
|
|
}
|
|
|
|
return vMenu
|
|
}
|
|
},
|
|
})
|