feat: add submenu support to CustomContextMenu
Add children support for nested menu items with hover-triggered submenus, auto-flip positioning near viewport edges, and smart close timing to prevent flicker.
This commit is contained in:
1 parent
cdadbc19f1
commit
6491442192
1 file changed
+196
-34
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onBeforeUnmount, nextTick, type Component } from "vue";
|
||||
import { ChevronRight } from "lucide-vue-next";
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
@@ -7,47 +8,56 @@ export interface ContextMenuItem {
|
||||
disabled?: boolean;
|
||||
separator?: boolean;
|
||||
icon?: Component;
|
||||
iconClass?: string;
|
||||
variant?: "default" | "destructive";
|
||||
visible?: boolean;
|
||||
children?: ContextMenuItem[];
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
items: ContextMenuItem[];
|
||||
}>();
|
||||
|
||||
// ---- module-level singleton state ----
|
||||
const openMenus = new Set<() => void>();
|
||||
let globalSetup = false;
|
||||
|
||||
function ensureGlobalListeners() {
|
||||
if (globalSetup) return;
|
||||
globalSetup = true;
|
||||
const closeAll = () => {
|
||||
for (const c of openMenus) c();
|
||||
openMenus.clear();
|
||||
};
|
||||
document.addEventListener("contextmenu", closeAll, true);
|
||||
document.addEventListener("scroll", closeAll, true);
|
||||
window.addEventListener("resize", closeAll);
|
||||
}
|
||||
ensureGlobalListeners();
|
||||
// -------------------------------------
|
||||
|
||||
const show = ref(false);
|
||||
const x = ref(0);
|
||||
const y = ref(0);
|
||||
const menuRef = ref<HTMLElement>();
|
||||
|
||||
function onContextMenu(event: MouseEvent) {
|
||||
if (props.items.length === 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
x.value = event.clientX;
|
||||
y.value = event.clientY;
|
||||
show.value = true;
|
||||
nextTick(() => adjustPosition());
|
||||
}
|
||||
// Submenu state
|
||||
const activeSubIndex = ref<number | null>(null);
|
||||
const subRef = ref<HTMLElement>();
|
||||
const subX = ref(0);
|
||||
const subY = ref(0);
|
||||
let subCloseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function close() {
|
||||
activeSubIndex.value = null;
|
||||
show.value = false;
|
||||
}
|
||||
|
||||
function adjustPosition() {
|
||||
if (!menuRef.value) return;
|
||||
const rect = menuRef.value.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
if (rect.right > vw) {
|
||||
x.value = Math.max(0, vw - rect.width - 8);
|
||||
}
|
||||
if (rect.bottom > vh) {
|
||||
y.value = Math.max(0, vh - rect.height - 8);
|
||||
}
|
||||
}
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (menuRef.value && !menuRef.value.contains(e.target as Node)) {
|
||||
const target = e.target as Node;
|
||||
const inMenu = menuRef.value?.contains(target);
|
||||
const inSub = subRef.value?.contains(target);
|
||||
if (!inMenu && !inSub) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
@@ -66,11 +76,13 @@ function onResize() {
|
||||
|
||||
watch(show, (val) => {
|
||||
if (val) {
|
||||
openMenus.add(close);
|
||||
document.addEventListener("click", onClickOutside, true);
|
||||
document.addEventListener("keydown", onKeydown);
|
||||
document.addEventListener("scroll", onScroll, true);
|
||||
window.addEventListener("resize", onResize);
|
||||
} else {
|
||||
openMenus.delete(close);
|
||||
document.removeEventListener("click", onClickOutside, true);
|
||||
document.removeEventListener("keydown", onKeydown);
|
||||
document.removeEventListener("scroll", onScroll, true);
|
||||
@@ -80,11 +92,126 @@ watch(show, (val) => {
|
||||
|
||||
function handleItemClick(item: ContextMenuItem) {
|
||||
if (item.disabled) return;
|
||||
if (item.children?.length) return; // submenu trigger — do nothing on click
|
||||
close();
|
||||
item.action?.();
|
||||
}
|
||||
|
||||
function handleSubItemClick(item: ContextMenuItem) {
|
||||
if (item.disabled) return;
|
||||
close();
|
||||
item.action?.();
|
||||
}
|
||||
|
||||
function onContextMenu(event: MouseEvent) {
|
||||
if (props.items.length === 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
x.value = event.clientX;
|
||||
y.value = event.clientY;
|
||||
show.value = true;
|
||||
nextTick(() => {
|
||||
if (!menuRef.value) return;
|
||||
const rect = menuRef.value.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
if (rect.right > vw) x.value = Math.max(0, vw - rect.width - 8);
|
||||
if (rect.bottom > vh) y.value = Math.max(0, vh - rect.height - 8);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- submenu ----
|
||||
|
||||
function onItemMouseEnter(index: number, event: MouseEvent) {
|
||||
lastMouseX = event.clientX;
|
||||
lastMouseY = event.clientY;
|
||||
const item = props.items[index];
|
||||
if (!item?.children?.length || item.disabled) {
|
||||
// Moving to an item without children — close submenu immediately, no delay needed
|
||||
activeSubIndex.value = null;
|
||||
return;
|
||||
}
|
||||
if (subCloseTimer) {
|
||||
clearTimeout(subCloseTimer);
|
||||
subCloseTimer = null;
|
||||
}
|
||||
const trigger = event.currentTarget as HTMLElement;
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
subX.value = rect.right + 4;
|
||||
subY.value = rect.top;
|
||||
activeSubIndex.value = index;
|
||||
nextTick(() => adjustSubPosition());
|
||||
}
|
||||
|
||||
function onItemMouseLeave() {
|
||||
if (activeSubIndex.value !== null) {
|
||||
scheduleSubClose();
|
||||
}
|
||||
}
|
||||
|
||||
// Last known mouse position (from hover events, without a global mousemove listener)
|
||||
let lastMouseX = 0;
|
||||
let lastMouseY = 0;
|
||||
|
||||
function isMouseOverSub(): boolean {
|
||||
if (!subRef.value) return false;
|
||||
const rect = subRef.value.getBoundingClientRect();
|
||||
return lastMouseX >= rect.left && lastMouseX <= rect.right && lastMouseY >= rect.top && lastMouseY <= rect.bottom;
|
||||
}
|
||||
|
||||
function scheduleSubClose() {
|
||||
if (subCloseTimer) clearTimeout(subCloseTimer);
|
||||
subCloseTimer = setTimeout(() => {
|
||||
if (!isMouseOverSub()) {
|
||||
activeSubIndex.value = null;
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function onSubMouseEnter() {
|
||||
if (subCloseTimer) {
|
||||
clearTimeout(subCloseTimer);
|
||||
subCloseTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onSubMouseLeave() {
|
||||
activeSubIndex.value = null;
|
||||
}
|
||||
|
||||
function adjustSubPosition() {
|
||||
if (!subRef.value) return;
|
||||
const rect = subRef.value.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
if (rect.right > vw) {
|
||||
subX.value = Math.max(0, vw - rect.width - 8);
|
||||
}
|
||||
if (rect.bottom > vh) {
|
||||
subY.value = Math.max(0, vh - rect.height - 8);
|
||||
}
|
||||
// When the submenu flips left due to right-edge overflow, it may land
|
||||
// under the mouse cursor. Since the mouse didn't move, mouseenter won't
|
||||
// fire — cancel any pending close to prevent the submenu from flashing.
|
||||
nextTick(() => {
|
||||
if (isMouseOverSub() && subCloseTimer) {
|
||||
clearTimeout(subCloseTimer);
|
||||
subCloseTimer = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function itemButtonClass(variant?: "default" | "destructive") {
|
||||
return [
|
||||
"w-full gap-2 rounded-md px-2 py-1 text-[13px] outline-hidden select-none text-left cursor-default flex items-center disabled:pointer-events-none disabled:opacity-50",
|
||||
variant === "destructive"
|
||||
? "text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
: "hover:bg-accent hover:text-accent-foreground",
|
||||
];
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
openMenus.delete(close);
|
||||
document.removeEventListener("click", onClickOutside, true);
|
||||
document.removeEventListener("keydown", onKeydown);
|
||||
document.removeEventListener("scroll", onScroll, true);
|
||||
@@ -94,6 +221,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<slot :on-context-menu="onContextMenu" />
|
||||
<!-- Main menu -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
@@ -102,16 +230,50 @@ onBeforeUnmount(() => {
|
||||
class="text-popover-foreground min-w-36 rounded-xl p-1 cn-menu-translucent overflow-x-hidden overflow-y-auto"
|
||||
>
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<div v-if="item.separator" class="bg-foreground/6 -mx-1 my-0.5 h-px" />
|
||||
<button
|
||||
v-else
|
||||
:disabled="item.disabled"
|
||||
class="w-full gap-2 rounded-md px-2 py-1 text-[13px] outline-hidden select-none text-left cursor-default flex items-center hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
||||
@click="handleItemClick(item)"
|
||||
>
|
||||
<component :is="item.icon" v-if="item.icon" class="size-4 shrink-0" />
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<template v-if="item.visible !== false">
|
||||
<div v-if="item.separator" class="bg-foreground/6 -mx-1 my-0.5 h-px" />
|
||||
<button
|
||||
v-else
|
||||
:disabled="item.disabled"
|
||||
:class="[
|
||||
...itemButtonClass(item.variant),
|
||||
activeSubIndex === index ? 'bg-accent text-accent-foreground' : '',
|
||||
]"
|
||||
@click="handleItemClick(item)"
|
||||
@mouseenter="(e) => onItemMouseEnter(index, e)"
|
||||
@mouseleave="onItemMouseLeave"
|
||||
>
|
||||
<component :is="item.icon" v-if="item.icon" :class="['size-4 shrink-0', item.iconClass]" />
|
||||
{{ item.label }}
|
||||
<ChevronRight v-if="item.children?.length" class="ml-auto size-4" />
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</Teleport>
|
||||
<!-- Submenu -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show && activeSubIndex !== null && items[activeSubIndex]?.children?.length"
|
||||
ref="subRef"
|
||||
:style="{ position: 'fixed', left: subX + 'px', top: subY + 'px', zIndex: 10000 }"
|
||||
class="text-popover-foreground min-w-36 rounded-xl p-1 cn-menu-translucent overflow-x-hidden overflow-y-auto"
|
||||
@mouseenter="onSubMouseEnter"
|
||||
@mouseleave="onSubMouseLeave"
|
||||
>
|
||||
<template v-for="(child, ci) in items[activeSubIndex]!.children!" :key="ci">
|
||||
<template v-if="child.visible !== false">
|
||||
<div v-if="child.separator" class="bg-foreground/6 -mx-1 my-0.5 h-px" />
|
||||
<button
|
||||
v-else
|
||||
:disabled="child.disabled"
|
||||
:class="itemButtonClass(child.variant)"
|
||||
@click="handleSubItemClick(child)"
|
||||
>
|
||||
<component :is="child.icon" v-if="child.icon" :class="['size-4 shrink-0', child.iconClass]" />
|
||||
{{ child.label }}
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
Reference in new issue
Block a user