fix(ui): unify list hover and selection highlighting
This commit is contained in:
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);
|
||||
|
||||
Reference in new issue
Block a user