<template>
|
<div class="mx-menu-ghost-host">
|
<Transition
|
v-if="options.menuTransitionProps"
|
appear
|
v-bind="options.menuTransitionProps"
|
@after-leave="emit('closeAnimFinished')"
|
>
|
<ContextSubMenuConstructor
|
v-if="show"
|
class="mx-menu-host"
|
:items="options.items"
|
:adjustPosition="options.adjustPosition"
|
:maxWidth="options.maxWidth || MenuConstOptions.defaultMaxWidth"
|
:minWidth="options.minWidth || MenuConstOptions.defaultMinWidth"
|
:direction="(options.direction || MenuConstOptions.defaultDirection as MenuPopDirection)"
|
>
|
<slot />
|
</ContextSubMenuConstructor>
|
</Transition>
|
<ContextSubMenuConstructor
|
v-else-if="show"
|
class="mx-menu-host"
|
:items="options.items"
|
:adjustPosition="options.adjustPosition"
|
:maxWidth="options.maxWidth || MenuConstOptions.defaultMaxWidth"
|
:minWidth="options.minWidth || MenuConstOptions.defaultMinWidth"
|
:direction="(options.direction || MenuConstOptions.defaultDirection as MenuPopDirection)"
|
>
|
<slot />
|
</ContextSubMenuConstructor>
|
</div>
|
</template>
|
|
<script setup lang="ts">
|
import {
|
h,
|
onBeforeUnmount,
|
onMounted,
|
type PropType,
|
provide,
|
ref,
|
renderSlot,
|
toRefs,
|
type VNode,
|
watch,
|
Transition,
|
useSlots,
|
type Ref,
|
} from "vue";
|
import type {
|
MenuItem,
|
MenuOptions,
|
MenuPopDirection,
|
} from "./ContextMenuDefine";
|
import { MenuConstOptions } from "./ContextMenuDefine";
|
import {
|
addOpenedContextMenu,
|
removeOpenedContextMenu,
|
} from "./ContextMenuMutex";
|
import ContextSubMenuConstructor, {
|
type SubMenuContext,
|
type SubMenuParentContext,
|
} from "./ContextSubMenu.vue";
|
|
/**
|
* Context menu component
|
*/
|
|
export type GlobalHasSlot = (name: string) => boolean;
|
export type GlobalRenderSlot = (
|
name: string,
|
params: Record<string, unknown>
|
) => VNode;
|
|
const props = defineProps({
|
/**
|
* Menu options
|
*/
|
options: {
|
type: Object as PropType<MenuOptions>,
|
default: null,
|
},
|
/**
|
* Show menu?
|
*/
|
show: {
|
type: Object as PropType<Ref<boolean>>,
|
default: null,
|
},
|
/**
|
* Current container, For calculation only
|
*/
|
container: {
|
type: Object as PropType<HTMLElement>,
|
default: null,
|
},
|
/**
|
* Make sure is user set the custom container.
|
*/
|
isFullScreenContainer: {
|
type: Boolean,
|
default: true,
|
},
|
});
|
|
const emit = defineEmits(["close", "closeAnimFinished"]);
|
|
const slots = useSlots();
|
|
const { options, show, container } = toRefs(props);
|
|
onMounted(() => {
|
if (show.value) openMenu();
|
});
|
onBeforeUnmount(() => {
|
removeBodyEvents();
|
});
|
|
watch(show, (v: boolean) => {
|
if (v) {
|
openMenu();
|
} else {
|
removeBodyEvents();
|
}
|
});
|
|
const instance = {
|
closeMenu,
|
isClosed,
|
};
|
let closed = false;
|
|
function openMenu() {
|
installBodyEvents();
|
addOpenedContextMenu(instance);
|
}
|
function closeMenu(fromItem?: MenuItem | undefined) {
|
closed = true;
|
emit("close", fromItem);
|
if (!options.value.menuTransitionProps) emit("closeAnimFinished");
|
removeOpenedContextMenu(instance);
|
}
|
function isClosed() {
|
return closed;
|
}
|
|
function installBodyEvents() {
|
setTimeout(() => {
|
document.addEventListener("click", onBodyClick, true);
|
document.addEventListener("contextmenu", onBodyClick, true);
|
document.addEventListener("scroll", onBodyScroll, true);
|
if (!props.isFullScreenContainer && container.value)
|
container.value.addEventListener("scroll", onBodyScroll, true);
|
if (options.value.keyboardControl !== false)
|
document.addEventListener("keydown", onMenuKeyDown);
|
}, 50);
|
}
|
function removeBodyEvents() {
|
document.removeEventListener("contextmenu", onBodyClick, true);
|
document.removeEventListener("click", onBodyClick, true);
|
document.removeEventListener("scroll", onBodyScroll, true);
|
if (!props.isFullScreenContainer && container.value)
|
container.value.removeEventListener("scroll", onBodyScroll, true);
|
if (options.value.keyboardControl !== false)
|
document.removeEventListener("keydown", onMenuKeyDown);
|
}
|
|
//For keyboard event, remember which submenu is active
|
const currentOpenedMenu = ref<SubMenuContext | null>();
|
provide(
|
"globalSetCurrentSubMenu",
|
(menu: SubMenuContext | null) => (currentOpenedMenu.value = menu)
|
);
|
|
function onMenuKeyDown(e: KeyboardEvent) {
|
let handled = true;
|
//Handle keyboard event
|
switch (e.key) {
|
case "Escape": {
|
if (currentOpenedMenu.value?.isTopLevel() === false) {
|
currentOpenedMenu.value?.closeCurrentSubMenu();
|
} else {
|
closeMenu();
|
}
|
break;
|
}
|
case "ArrowDown":
|
currentOpenedMenu.value?.moveCurrentItemDown();
|
break;
|
case "ArrowUp":
|
currentOpenedMenu.value?.moveCurrentItemUp();
|
break;
|
case "Home":
|
currentOpenedMenu.value?.moveCurrentItemFirst();
|
break;
|
case "End":
|
currentOpenedMenu.value?.moveCurrentItemLast();
|
break;
|
case "ArrowLeft": {
|
if (!currentOpenedMenu.value?.closeSelfAndActiveParent())
|
options.value.onKeyFocusMoveLeft?.();
|
break;
|
}
|
case "ArrowRight":
|
if (!currentOpenedMenu.value?.openCurrentItemSubMenu())
|
options.value.onKeyFocusMoveRight?.();
|
break;
|
case "Enter":
|
currentOpenedMenu.value?.triggerCurrentItemClick(e);
|
break;
|
default:
|
handled = false;
|
break;
|
}
|
if (handled && currentOpenedMenu.value) {
|
e.stopPropagation();
|
e.preventDefault();
|
}
|
}
|
function onBodyScroll() {
|
//close when docunment scroll
|
if (options.value.closeWhenScroll !== false) closeMenu();
|
}
|
function onBodyClick(e: MouseEvent) {
|
checkTargetAndClose(e.target as HTMLElement);
|
}
|
function checkTargetAndClose(target: HTMLElement) {
|
//Loop target , Check whether the currently clicked element belongs to the current menu.
|
// If yes, it will not be closed
|
while (target) {
|
if (target.classList && target.classList.contains("mx-menu-host")) return;
|
target = target.parentNode as HTMLElement;
|
}
|
if (options.value.clickCloseOnOutside !== false) {
|
//Close menu
|
removeBodyEvents();
|
closeMenu();
|
}
|
}
|
|
//provide globalOptions for child use
|
provide("globalOptions", options.value);
|
provide("globalCloseMenu", closeMenu);
|
provide("globalTheme", options.value?.theme || "light");
|
provide("globalIsFullScreenContainer", props.isFullScreenContainer);
|
provide("globalClickCloseClassName", options.value?.clickCloseClassName);
|
provide("globalIgnoreClickClassName", options.value?.ignoreClickClassName);
|
provide("globalIconFontClass", options.value?.iconFontClass || "iconfont");
|
provide("globalMenuTransitionProps", options.value?.menuTransitionProps);
|
//check slot exists
|
provide("globalHasSlot", (name: string) => {
|
return slots[name] !== undefined;
|
});
|
//render slot
|
provide(
|
"globalRenderSlot",
|
(name: string, params: Record<string, unknown>) => {
|
return renderSlot(
|
slots,
|
name,
|
{ ...params },
|
() => [h("span", "Render slot failed")],
|
false
|
);
|
}
|
);
|
//provide menuContext for child use
|
provide("menuContext", {
|
zIndex: options.value.zIndex || MenuConstOptions.defaultZindex,
|
container: container.value as unknown as HTMLElement,
|
adjustPadding: { x: 0, y: 0 },
|
getParentAbsY: () => options.value.x,
|
getParentAbsX: () => options.value.y,
|
getParentX: () => 0,
|
getParentY: () => 0,
|
getParentWidth: () => 0,
|
getParentHeight: () => 0,
|
getPositon: () => [options.value.x, options.value.y],
|
closeOtherSubMenuWithTimeOut: () => {
|
/* Do nothing */
|
},
|
checkCloseOtherSubMenuTimeOut: () => false,
|
addOpenedSubMenu: () => {
|
/* Do nothing */
|
},
|
closeOtherSubMenu: () => {
|
/* Do nothing */
|
},
|
getParentContext: () => null,
|
getSubMenuInstanceContext: () => null,
|
getElement: () => null,
|
addChildMenuItem: () => {
|
/* Do nothing */
|
},
|
removeChildMenuItem: () => {
|
/* Do nothing */
|
},
|
markActiveMenuItem: () => {
|
/* Do nothing */
|
},
|
markThisOpenedByKeyBoard: () => {
|
/* Do nothing */
|
},
|
isOpenedByKeyBoardFlag: () => false,
|
isMenuItemDataCollectedFlag: () => false,
|
} as SubMenuParentContext);
|
|
//Expose instance function
|
defineExpose(instance);
|
</script>
|
|
<style>
|
.mx-menu-ghost-host {
|
position: absolute;
|
left: 0;
|
bottom: 0;
|
right: 0;
|
top: 0;
|
overflow: hidden;
|
pointer-events: none;
|
}
|
.mx-menu-ghost-host.information_full_screen {
|
position: fixed;
|
}
|
</style>
|