fix(ui): unify list hover and selection highlighting

This commit is contained in:
zipg authored and GitHub committed 2026-08-12 17:33:01 +08:00
1 parent d0b67ae795
commit fc2db2a17c
11 files changed
+142 -31

No files matched your search

@@ -598,8 +598,8 @@ onBeforeUnmount(() => {
<div class="flex min-h-0 flex-1 flex-col">
<RecycleScroller v-if="shouldVirtualizeHistory(store.entries.length)" class="min-h-0 flex-1" :items="store.entries" :item-size="HISTORY_ROW_HEIGHT" :buffer="HISTORY_SCROLL_BUFFER" :skip-hover="true" key-field="id">
<template #default="{ item: entry }">
<CustomContextMenu :items="getHistoryMenuItems(entry)" v-slot="{ onContextMenu }">
<div class="h-[72px] cursor-pointer border-b border-border/50 px-3 py-2 text-xs hover:bg-accent/50" @click="selectedEntry = entry" @contextmenu="onContextMenu">
<CustomContextMenu :items="getHistoryMenuItems(entry)" v-slot="{ onContextMenu, isOpen }">
<div class="h-[72px] cursor-pointer select-none border-b border-border/50 px-3 py-2 text-xs" :class="isOpen || selectedEntry?.id === entry.id ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/40'" @click="selectedEntry = entry" @contextmenu="onContextMenu">
<div class="mb-0.5 flex items-center gap-1">
<span class="inline-flex h-5 w-9 shrink-0 items-center justify-center rounded border px-1 text-[10px] leading-none text-muted-foreground">
{{ kindShortLabel(entry) }}
@@ -1870,10 +1870,10 @@ defineExpose({
</div>
<div v-else class="h-full overflow-auto py-1 text-sm">
<template v-for="row in visibleRows" :key="row.type === 'node' ? row.node.id : row.id">
<CustomContextMenu v-if="row.type === 'node'" :items="nodeContextMenuItems(row.node)" v-slot="{ onContextMenu }">
<CustomContextMenu v-if="row.type === 'node'" :items="nodeContextMenuItems(row.node)" v-slot="{ onContextMenu, isOpen }">
<div
class="flex h-8 w-full select-none items-center gap-1.5 pr-2 text-left transition-colors hover:bg-accent"
:class="rowIsSelected(row.node) ? 'bg-primary/10 font-medium text-foreground shadow-[inset_3px_0_0_hsl(var(--primary))]' : ''"
class="flex h-8 w-full select-none items-center gap-1.5 pr-2 text-left transition-colors"
:class="isOpen || rowIsSelected(row.node) ? 'bg-accent font-medium text-accent-foreground shadow-[inset_3px_0_0_hsl(var(--primary))]' : 'hover:bg-accent/40'"
:style="{ paddingLeft: `${8 + row.depth * 18}px` }"
@mousedown.right.prevent
@contextmenu="(event) => onRowContextMenu(event, row.node, onContextMenu)"
@@ -381,8 +381,8 @@ function clearContextTarget() {
<div v-else>
<div v-for="(folder, fi) in folders" :key="folder.path" class="border-b last:border-b-0">
<div
class="flex cursor-default items-center gap-1 px-2 py-1.5 text-[11px] font-medium text-muted-foreground bg-muted/10 sticky top-0 select-none hover:bg-muted/30"
:class="selectedPath === folder.path ? 'bg-accent/60 text-accent-foreground' : ''"
class="flex cursor-default items-center gap-1 px-2 py-1.5 text-[11px] font-medium text-muted-foreground bg-muted/10 sticky top-0 select-none"
:class="selectedPath === folder.path ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/40'"
@click="
toggleFolderCollapse(folder);
selectPath(folder.path);
@@ -429,8 +429,8 @@ function clearContextTarget() {
<div
v-for="{ entry, depth } in flatTree(folder.entries, folder.expanded)"
:key="entry.path"
class="flex cursor-default items-center gap-1 px-2 py-1 hover:bg-muted/60 text-sm"
:class="[entry.is_dir ? 'rounded-sm' : 'rounded-none', selectedPath === entry.path ? 'bg-accent text-accent-foreground' : '']"
class="flex cursor-default select-none items-center gap-1 px-2 py-1 text-sm"
:class="[entry.is_dir ? 'rounded-sm' : 'rounded-none', selectedPath === entry.path ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/40']"
:style="{ paddingLeft: depth * 16 + 8 + 'px' }"
@click="
selectPath(entry.path);
@@ -517,22 +517,21 @@ function isFolderActive(folderId: string): boolean {
return activeItemType.value === "folder" && activeItemId.value === folderId;
}
function selectionRowClass(selected: boolean, active: boolean): string {
if (selected) return "bg-primary/10 text-foreground";
if (active) return "bg-primary/12 text-foreground";
return "hover:bg-accent";
function selectionRowClass(selected: boolean, active: boolean, contextOpen: boolean): string {
if (selected || active || contextOpen) return "bg-accent text-accent-foreground";
return "hover:bg-accent/40";
}
function fileRowClass(fileId: string): string {
return selectionRowClass(isFileSelected(fileId), isFileActive(fileId));
return selectionRowClass(isFileSelected(fileId), isFileActive(fileId), isContextFile(fileId));
}
function folderRowClass(folderId: string): string {
return selectionRowClass(isFolderSelected(folderId), isFolderActive(folderId));
return selectionRowClass(isFolderSelected(folderId), isFolderActive(folderId), isContextFolder(folderId));
}
function fileMetaClass(fileId: string): string {
return isFileSelected(fileId) || isFileActive(fileId) ? "text-foreground/70" : "text-muted-foreground";
return isFileSelected(fileId) || isFileActive(fileId) || isContextFile(fileId) ? "text-accent-foreground" : "text-muted-foreground";
}
function isFileDirty(file: SavedSqlFile): boolean {
@@ -798,6 +797,14 @@ function handleFolderClick(folder: SavedSqlFolder, event: MouseEvent) {
const contextTarget = ref<SavedSqlFolder | SavedSqlFile | "panel" | null>(null);
function isContextFile(fileId: string): boolean {
return contextTarget.value !== null && contextTarget.value !== "panel" && "sql" in contextTarget.value && contextTarget.value.id === fileId;
}
function isContextFolder(folderId: string): boolean {
return contextTarget.value !== null && contextTarget.value !== "panel" && !("sql" in contextTarget.value) && contextTarget.value.id === folderId;
}
function folderMoveMenuItems(fileIds: string[]): CtxMenuItem[] {
const files = [...new Set(fileIds)].map((id) => savedSqlStore.getFile(id)).filter((file): file is SavedSqlFile => Boolean(file));
const allInUnfiled = files.length > 0 && files.every((file) => !file.folderId);
@@ -0,0 +1,12 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const panelSource = readFileSync(new URL("../SqlLibraryPanel.vue", import.meta.url), "utf8");
describe("SqlLibraryPanel selection contrast", () => {
it("uses the accent foreground for selected rows and their metadata", () => {
expect(panelSource).toContain('return "bg-accent text-accent-foreground";');
expect(panelSource).toMatch(/function fileMetaClass[\s\S]*\? "text-accent-foreground" : "text-muted-foreground";/);
expect(panelSource).not.toContain('contextFile(fileId) ? "text-foreground/70"');
});
});
@@ -3029,13 +3029,12 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</div>
<RecycleScroller ref="listScrollerRef" class="object-browser-scroller min-h-0 flex-1" :style="{ minWidth: `${objectGridMinWidth}px` }" :items="filteredRows" :item-size="34" :buffer="600" :skip-hover="true" key-field="id">
<template #default="{ item }">
<CustomContextMenu :items="() => getObjectBrowserMenuItems(item)" v-slot="{ onContextMenu }">
<CustomContextMenu :items="() => getObjectBrowserMenuItems(item)" v-slot="{ onContextMenu, isOpen }">
<div
class="grid h-[34px] cursor-default items-center gap-3 border-b px-3 hover:bg-accent/50"
class="grid h-[34px] cursor-default items-center gap-3 border-b px-3"
:class="{
'bg-accent/40': sourceRow?.id === item.id,
'bg-primary/10': sidePanelRow?.id === item.id && !selectedTableIds.has(item.id),
'bg-primary/5': selectedTableIds.has(item.id),
'bg-accent text-accent-foreground': isOpen || sourceRow?.id === item.id || sidePanelRow?.id === item.id || selectedTableIds.has(item.id),
'hover:bg-accent/40': !isOpen && sourceRow?.id !== item.id && sidePanelRow?.id !== item.id && !selectedTableIds.has(item.id),
}"
:style="{ gridTemplateColumns, boxShadow: sidePanelRow?.id === item.id && !selectedTableIds.has(item.id) ? 'inset 3px 0 0 var(--primary)' : undefined }"
@click="onRowClick(item, $event)"
@@ -3088,13 +3087,12 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<RecycleScroller ref="gridScrollerRef" v-if="gridRows.length > 0" class="object-browser-grid-scroller h-full" :items="gridRows" :item-size="objectGridRowHeight" :buffer="600" :skip-hover="true" key-field="key">
<template #default="{ item: row }">
<div class="object-browser-grid-row" :style="{ gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`, height: `${objectGridRowHeight - OBJECT_GRID_GAP}px` }">
<CustomContextMenu v-for="item in row.cards" :key="item.id" :items="() => getObjectBrowserMenuItems(item)" v-slot="{ onContextMenu }">
<CustomContextMenu v-for="item in row.cards" :key="item.id" :items="() => getObjectBrowserMenuItems(item)" v-slot="{ onContextMenu, isOpen }">
<div
class="relative flex h-full min-h-0 cursor-default flex-col items-center gap-1 rounded-lg border bg-card p-3 text-center transition-all hover:border-primary/40 hover:shadow-sm"
class="relative flex h-full min-h-0 cursor-default flex-col items-center gap-1 rounded-lg border p-3 text-center transition-all"
:class="{
'border-primary bg-primary/5': selectedTableIds.has(item.id),
'border-primary/60': sourceRow?.id === item.id && !selectedTableIds.has(item.id),
'border-primary bg-primary/8 shadow-sm': sidePanelRow?.id === item.id && !selectedTableIds.has(item.id),
'border-primary bg-accent shadow-sm': isOpen || sourceRow?.id === item.id || sidePanelRow?.id === item.id || selectedTableIds.has(item.id),
'bg-card hover:border-primary/40 hover:bg-accent/40 hover:shadow-sm': !isOpen && sourceRow?.id !== item.id && sidePanelRow?.id !== item.id && !selectedTableIds.has(item.id),
}"
:title="item.displayName"
@click="onRowClick(item, $event)"
@@ -1802,10 +1802,10 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
</div>
<RecycleScroller v-else class="redis-key-scroller flex-1" :items="visibleRows" :item-size="30" :buffer="600" :skip-hover="true" key-field="id" @scroll="onRedisKeyScroll">
<template #default="{ item: row }">
<CustomContextMenu :items="redisKeyContextMenuItems(row.node)" v-slot="{ onContextMenu }">
<CustomContextMenu :items="redisKeyContextMenuItems(row.node)" v-slot="{ onContextMenu, isOpen }">
<div
class="flex items-center gap-2 border-b px-3 text-[13px] cursor-pointer hover:bg-accent/50 group"
:class="{ 'bg-accent': row.node.kind === 'leaf' && selectedKeyRaw === row.node.keyRaw }"
class="flex items-center gap-2 border-b px-3 text-[13px] cursor-pointer group"
:class="isOpen || (row.node.kind === 'leaf' && selectedKeyRaw === row.node.keyRaw) ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/40'"
:style="{ height: '30px' }"
@click="onRowClick(row.node)"
@contextmenu="(event) => onRedisRowContextMenu(event, row.node, onContextMenu)"
@@ -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);