fix(ui): unify list hover and selection highlighting
This commit is contained in:
11 files changed
+142
-31
No files matched your search
@@ -13,11 +13,12 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: [];
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
defineSlots<{
|
||||
default(props: { onContextMenu: (event: MouseEvent) => void }): any;
|
||||
default(props: { onContextMenu: (event: MouseEvent) => void; isOpen: boolean }): any;
|
||||
}>();
|
||||
|
||||
const show = ref(false);
|
||||
@@ -36,6 +37,7 @@ let subAnchorRect: { left: number; right: number; top: number; bottom: number }
|
||||
let contextMenuRegistration: ContextMenuRegistration | null = null;
|
||||
|
||||
function close() {
|
||||
if (!show.value) return;
|
||||
activeSubIndex.value = null;
|
||||
subAnchorRect = null;
|
||||
activeItems.value = [];
|
||||
@@ -125,7 +127,9 @@ function onContextMenu(event: MouseEvent, itemsOverride?: ContextMenuItem[]) {
|
||||
event.stopPropagation();
|
||||
x.value = event.clientX;
|
||||
y.value = event.clientY;
|
||||
contextMenuRegistration?.activate();
|
||||
show.value = true;
|
||||
emit("open");
|
||||
nextTick(() => {
|
||||
if (!menuRef.value) return;
|
||||
const rect = menuRef.value.getBoundingClientRect();
|
||||
@@ -258,7 +262,7 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot :onContextMenu="onContextMenu" />
|
||||
<slot :onContextMenu="onContextMenu" :isOpen="show" />
|
||||
<!-- Main menu -->
|
||||
<Teleport to="body">
|
||||
<div v-if="show" ref="menuRef" data-dbx-context-menu :style="{ position: 'fixed', left: x + 'px', top: y + 'px', zIndex: 9999 }" class="bg-popover text-popover-foreground min-w-40 w-max max-w-[calc(100vw-16px)] rounded-md p-1 overflow-y-auto ring-1 ring-foreground/10 shadow-lg">
|
||||
|
||||
@@ -20,6 +20,62 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("CustomContextMenu lifecycle", () => {
|
||||
it("exposes the open state and closes only the active target", async () => {
|
||||
const firstOpen = vi.fn();
|
||||
const firstClose = vi.fn();
|
||||
const secondOpen = vi.fn();
|
||||
const secondClose = vi.fn();
|
||||
const root = defineComponent({
|
||||
setup() {
|
||||
const menu = (id: string, onOpen: () => void, onClose: () => void) =>
|
||||
h(
|
||||
CustomContextMenu,
|
||||
{ items: [{ label: `Inspect ${id}` }], onOpen, onClose },
|
||||
{
|
||||
default: ({ onContextMenu, isOpen }: { onContextMenu: (event: MouseEvent) => void; isOpen: boolean }) => h("div", { id, "data-context-open": String(isOpen), onContextmenu: onContextMenu }, id),
|
||||
},
|
||||
);
|
||||
return () => [menu("first-target", firstOpen, firstClose), menu("second-target", secondOpen, secondClose)];
|
||||
},
|
||||
});
|
||||
const container = document.createElement("div");
|
||||
mountedContainers.push(container);
|
||||
document.body.append(container);
|
||||
const app = createApp(root);
|
||||
app.mount(container);
|
||||
|
||||
const firstTarget = container.querySelector("#first-target");
|
||||
const secondTarget = container.querySelector("#second-target");
|
||||
firstTarget?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
|
||||
await nextTick();
|
||||
expect(firstTarget?.getAttribute("data-context-open")).toBe("true");
|
||||
expect(secondTarget?.getAttribute("data-context-open")).toBe("false");
|
||||
expect(firstOpen).toHaveBeenCalledTimes(1);
|
||||
expect(firstClose).not.toHaveBeenCalled();
|
||||
|
||||
firstTarget?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
|
||||
await nextTick();
|
||||
expect(firstTarget?.getAttribute("data-context-open")).toBe("true");
|
||||
expect(secondTarget?.getAttribute("data-context-open")).toBe("false");
|
||||
expect(firstOpen).toHaveBeenCalledTimes(2);
|
||||
expect(firstClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
secondTarget?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
|
||||
await nextTick();
|
||||
expect(firstTarget?.getAttribute("data-context-open")).toBe("false");
|
||||
expect(secondTarget?.getAttribute("data-context-open")).toBe("true");
|
||||
expect(firstClose).toHaveBeenCalledTimes(2);
|
||||
expect(secondOpen).toHaveBeenCalledTimes(1);
|
||||
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
await nextTick();
|
||||
expect(secondTarget?.getAttribute("data-context-open")).toBe("false");
|
||||
expect(secondClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("removes the capture keydown listener when unmounted while open", async () => {
|
||||
const documentAdd = vi.spyOn(document, "addEventListener");
|
||||
const documentRemove = vi.spyOn(document, "removeEventListener");
|
||||
|
||||
@@ -59,6 +59,30 @@ describe("customContextMenuRegistry", () => {
|
||||
registration.dispose();
|
||||
});
|
||||
|
||||
it("atomically reactivates a host after the capture listener clears it", () => {
|
||||
const documentTarget = new EventTarget();
|
||||
const windowTarget = new EventTarget();
|
||||
const registry = createContextMenuRegistry(documentTarget, windowTarget);
|
||||
const closeFirst = vi.fn();
|
||||
const closeSecond = vi.fn();
|
||||
const first = registry.register(closeFirst);
|
||||
const second = registry.register(closeSecond);
|
||||
|
||||
first.activate();
|
||||
documentTarget.dispatchEvent(new Event("contextmenu"));
|
||||
first.activate();
|
||||
second.activate();
|
||||
|
||||
expect(closeFirst).toHaveBeenCalledTimes(2);
|
||||
expect(closeSecond).not.toHaveBeenCalled();
|
||||
|
||||
windowTarget.dispatchEvent(new Event("resize"));
|
||||
expect(closeSecond).toHaveBeenCalledOnce();
|
||||
|
||||
first.dispose();
|
||||
second.dispose();
|
||||
});
|
||||
|
||||
it("removes disposed callbacks and detaches listeners after the final host", () => {
|
||||
const documentTarget = new EventTarget();
|
||||
const windowTarget = new EventTarget();
|
||||
|
||||
@@ -26,6 +26,7 @@ export function isContextMenuInternalScroll(event: Event): boolean {
|
||||
}
|
||||
|
||||
export interface ContextMenuRegistration {
|
||||
activate(): void;
|
||||
setOpen(open: boolean): void;
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -75,6 +76,15 @@ export function createContextMenuRegistry(documentTarget: EventTarget, windowTar
|
||||
let disposed = false;
|
||||
|
||||
return {
|
||||
activate() {
|
||||
if (disposed) return;
|
||||
const closers = [...openMenus];
|
||||
openMenus.clear();
|
||||
for (const activeClose of closers) {
|
||||
if (activeClose !== close) activeClose();
|
||||
}
|
||||
openMenus.add(close);
|
||||
},
|
||||
setOpen(open) {
|
||||
if (disposed) return;
|
||||
if (open) openMenus.add(close);
|
||||
|
||||
Reference in new issue
Block a user