fix(objects): pin object list vertical scrollbar to the viewport

When the object-browser columns exceeded the panel width, the vertical
scrollbar travelled with the content: the row scroller was stretched to
the content min-width inside the horizontally scrolling table container,
so its scrollbar sat at the far-right edge of the (off-screen) content
and users had to scroll all the way right to reach it (#8885).

The list scroller now stays viewport-width and owns the horizontal
scrolling itself (content min-width moves to the virtual-scroller item
wrapper), and the header becomes a clipped wrapper whose scrollLeft is
kept in sync with the scroller on scroll, resize, and re-attach.
This commit is contained in:
wxp
2026-09-12 00:17:14 +08:00
parent 48d6c94aad
commit 3affa544a9
2 changed files with 159 additions and 86 deletions
@@ -433,6 +433,7 @@ type ObjectBrowserScroller =
// type loose because vue-virtual-scroller does not ship complete ref typings.
const listScrollerRef = ref<ObjectBrowserScroller | null>(null);
const gridScrollerRef = ref<ObjectBrowserScroller | null>(null);
const objectListHeaderRef = ref<HTMLElement | null>(null);
let viewportFrame = 0;
let restoreViewportFrame = 0;
@@ -463,6 +464,7 @@ function emitViewportChange(scrollTop: number) {
}
function onObjectsScroll() {
syncObjectListHeaderScroll();
if (viewportFrame) return;
viewportFrame = window.requestAnimationFrame(() => {
viewportFrame = 0;
@@ -472,6 +474,17 @@ function onObjectsScroll() {
});
}
// The list header sits outside the row scroller and is clipped (overflow:
// hidden), so keep its programmatic scrollLeft aligned with the scroller's
// horizontal position on every scroll, resize, and (re)attach.
function syncObjectListHeaderScroll() {
if (!isListView) return;
const header = objectListHeaderRef.value;
const el = scrollerElement(listScrollerRef.value);
if (!header || !el) return;
if (header.scrollLeft !== el.scrollLeft) header.scrollLeft = el.scrollLeft;
}
function flushObjectBrowserViewport() {
if (viewportFrame) {
window.cancelAnimationFrame(viewportFrame);
@@ -522,6 +535,7 @@ watch(
if (!el) return;
el.addEventListener("scroll", onObjectsScroll, { passive: true });
restoreObjectBrowserViewport();
nextTick(() => syncObjectListHeaderScroll());
onCleanup(() => el.removeEventListener("scroll", onObjectsScroll));
},
{ flush: "post" },
@@ -697,6 +711,14 @@ const selectedTableRows = computed(() => {
const selectedTableCount = computed(() => selectedTableRows.value.length);
const canBatchDropCascade = computed(() => selectedTableCount.value > 0 && supportsDropTableCascade(effectiveDatabaseType.value));
const canBatchTruncateCascade = computed(() => selectedTableCount.value > 0 && supportsTruncateTableCascade(effectiveDatabaseType.value));
// Column resizes change the scrollable content width, so the header's clamped
// scrollLeft has to be re-aligned right after the DOM updates. Registered here
// (after every computed it transitively reads) because watch sources are
// evaluated eagerly at registration time.
watch([objectGridMinWidth, objectColumnWidths], () => {
nextTick(() => syncObjectListHeaderScroll());
});
const allVisibleTablesSelected = computed(() => visibleSelectableRows.value.length > 0 && visibleSelectableRows.value.every((row) => selectedTableIds.value.has(row.id)));
const batchDropProgressPercent = computed(() => (batchDropProgress.value.total > 0 ? Math.round((batchDropProgress.value.completed / batchDropProgress.value.total) * 100) : 0));
@@ -3470,102 +3492,104 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</div>
<div v-else class="flex min-h-0 min-w-0 flex-1" :class="{ 'event-editor-layout': isEventEditor }">
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
<div v-if="isListView" class="object-browser-table flex min-h-0 min-w-0 flex-1 flex-col overflow-x-auto overflow-y-hidden">
<div class="grid h-7 shrink-0 items-center gap-3 border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground" :style="{ gridTemplateColumns, minWidth: `${objectGridMinWidth}px` }">
<div v-if="showCheckboxColumn" class="relative flex min-w-0 items-center">
<button class="flex h-6 w-6 items-center justify-center rounded-sm hover:bg-accent" type="button" :disabled="visibleSelectableRows.length === 0" @click="toggleVisibleTableSelection">
<CheckSquare v-if="allVisibleTablesSelected" class="h-3.5 w-3.5 text-primary" />
<Square v-else class="h-3.5 w-3.5" />
</button>
<div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('select', $event)" @dblclick="resetObjectColumnWidth('select', 34, $event)">
<GripVertical class="h-3 w-3" />
<div v-if="isListView" ref="objectListTableRef" class="object-browser-table flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div ref="objectListHeaderRef" class="h-7 shrink-0 overflow-hidden">
<div class="grid h-7 items-center gap-3 border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground" :style="{ gridTemplateColumns, minWidth: `${objectGridMinWidth}px` }">
<div v-if="showCheckboxColumn" class="relative flex min-w-0 items-center">
<button class="flex h-6 w-6 items-center justify-center rounded-sm hover:bg-accent" type="button" :disabled="visibleSelectableRows.length === 0" @click="toggleVisibleTableSelection">
<CheckSquare v-if="allVisibleTablesSelected" class="h-3.5 w-3.5 text-primary" />
<Square v-else class="h-3.5 w-3.5" />
</button>
<div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('select', $event)" @dblclick="resetObjectColumnWidth('select', 34, $event)">
<GripVertical class="h-3 w-3" />
</div>
</div>
</div>
<div class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('name')">
<span class="truncate">{{ t("objects.name") }}</span>
<component :is="sortIconFor('name')" v-if="sortIconFor('name')" class="h-3 w-3 shrink-0" />
</button>
<div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('name', $event)" @dblclick="resetObjectColumnWidth('name', 260, $event)">
<GripVertical class="h-3 w-3" />
<div class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('name')">
<span class="truncate">{{ t("objects.name") }}</span>
<component :is="sortIconFor('name')" v-if="sortIconFor('name')" class="h-3 w-3 shrink-0" />
</button>
<div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('name', $event)" @dblclick="resetObjectColumnWidth('name', 260, $event)">
<GripVertical class="h-3 w-3" />
</div>
</div>
</div>
<div class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('type')">
<span class="truncate">{{ t("objects.type") }}</span>
<component :is="sortIconFor('type')" v-if="sortIconFor('type')" class="h-3 w-3 shrink-0" />
</button>
<div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('type', $event)" @dblclick="resetObjectColumnWidth('type', 110, $event)">
<GripVertical class="h-3 w-3" />
<div class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('type')">
<span class="truncate">{{ t("objects.type") }}</span>
<component :is="sortIconFor('type')" v-if="sortIconFor('type')" class="h-3 w-3 shrink-0" />
</button>
<div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('type', $event)" @dblclick="resetObjectColumnWidth('type', 110, $event)">
<GripVertical class="h-3 w-3" />
</div>
</div>
</div>
<div v-if="showObjectRowStats" class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :title="t('objects.statisticsHint')" @click="toggleSort('estimatedRows')">
<span class="truncate">{{ objectRowsLabel }}</span>
<component :is="sortIconFor('estimatedRows')" v-if="sortIconFor('estimatedRows')" class="h-3 w-3 shrink-0" />
</button>
<div
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
@mousedown="onObjectColumnResizeStart('estimatedRows', $event)"
@dblclick="resetObjectColumnWidth('estimatedRows', 110, $event)"
>
<GripVertical class="h-3 w-3" />
<div v-if="showObjectRowStats" class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :title="t('objects.statisticsHint')" @click="toggleSort('estimatedRows')">
<span class="truncate">{{ objectRowsLabel }}</span>
<component :is="sortIconFor('estimatedRows')" v-if="sortIconFor('estimatedRows')" class="h-3 w-3 shrink-0" />
</button>
<div
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
@mousedown="onObjectColumnResizeStart('estimatedRows', $event)"
@dblclick="resetObjectColumnWidth('estimatedRows', 110, $event)"
>
<GripVertical class="h-3 w-3" />
</div>
</div>
</div>
<div v-if="showObjectSizeStats" class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :title="t('objects.statisticsHint')" @click="toggleSort('totalBytes')">
<span class="truncate">{{ t("objects.size") }}</span>
<component :is="sortIconFor('totalBytes')" v-if="sortIconFor('totalBytes')" class="h-3 w-3 shrink-0" />
</button>
<div
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
@mousedown="onObjectColumnResizeStart('totalBytes', $event)"
@dblclick="resetObjectColumnWidth('totalBytes', 100, $event)"
>
<GripVertical class="h-3 w-3" />
<div v-if="showObjectSizeStats" class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :title="t('objects.statisticsHint')" @click="toggleSort('totalBytes')">
<span class="truncate">{{ t("objects.size") }}</span>
<component :is="sortIconFor('totalBytes')" v-if="sortIconFor('totalBytes')" class="h-3 w-3 shrink-0" />
</button>
<div
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
@mousedown="onObjectColumnResizeStart('totalBytes', $event)"
@dblclick="resetObjectColumnWidth('totalBytes', 100, $event)"
>
<GripVertical class="h-3 w-3" />
</div>
</div>
</div>
<div v-if="hasCreatedAt" class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('created_at')">
<span class="truncate">{{ t("objects.createdAt") }}</span>
<component :is="sortIconFor('created_at')" v-if="sortIconFor('created_at')" class="h-3 w-3 shrink-0" />
</button>
<div
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
@mousedown="onObjectColumnResizeStart('created_at', $event)"
@dblclick="resetObjectColumnWidth('created_at', 150, $event)"
>
<GripVertical class="h-3 w-3" />
<div v-if="hasCreatedAt" class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('created_at')">
<span class="truncate">{{ t("objects.createdAt") }}</span>
<component :is="sortIconFor('created_at')" v-if="sortIconFor('created_at')" class="h-3 w-3 shrink-0" />
</button>
<div
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
@mousedown="onObjectColumnResizeStart('created_at', $event)"
@dblclick="resetObjectColumnWidth('created_at', 150, $event)"
>
<GripVertical class="h-3 w-3" />
</div>
</div>
</div>
<div v-if="hasUpdatedAt" class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('updated_at')">
<span class="truncate">{{ t("objects.updatedAt") }}</span>
<component :is="sortIconFor('updated_at')" v-if="sortIconFor('updated_at')" class="h-3 w-3 shrink-0" />
</button>
<div
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
@mousedown="onObjectColumnResizeStart('updated_at', $event)"
@dblclick="resetObjectColumnWidth('updated_at', 150, $event)"
>
<GripVertical class="h-3 w-3" />
<div v-if="hasUpdatedAt" class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('updated_at')">
<span class="truncate">{{ t("objects.updatedAt") }}</span>
<component :is="sortIconFor('updated_at')" v-if="sortIconFor('updated_at')" class="h-3 w-3 shrink-0" />
</button>
<div
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
@mousedown="onObjectColumnResizeStart('updated_at', $event)"
@dblclick="resetObjectColumnWidth('updated_at', 150, $event)"
>
<GripVertical class="h-3 w-3" />
</div>
</div>
</div>
<div class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('comment')">
<span class="truncate">{{ t("objects.comment") }}</span>
<component :is="sortIconFor('comment')" v-if="sortIconFor('comment')" class="h-3 w-3 shrink-0" />
</button>
<div
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
@mousedown="onObjectColumnResizeStart('comment', $event)"
@dblclick="resetObjectColumnWidth('comment', 260, $event)"
>
<GripVertical class="h-3 w-3" />
<div class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('comment')">
<span class="truncate">{{ t("objects.comment") }}</span>
<component :is="sortIconFor('comment')" v-if="sortIconFor('comment')" class="h-3 w-3 shrink-0" />
</button>
<div
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
@mousedown="onObjectColumnResizeStart('comment', $event)"
@dblclick="resetObjectColumnWidth('comment', 260, $event)"
>
<GripVertical class="h-3 w-3" />
</div>
</div>
</div>
</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">
<RecycleScroller ref="listScrollerRef" class="object-browser-scroller min-h-0 flex-1" :style="{ '--dbx-object-grid-min-width': `${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, isOpen }">
<div
@@ -4136,6 +4160,14 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
.object-browser-scroller {
will-change: scroll-position;
contain: content;
overflow-x: auto;
}
/* The scroller itself stays viewport-width so its vertical scrollbar remains
visible at the right edge; the row content inside scrolls horizontally past
that width instead (issue #8885). */
.object-browser-scroller :deep(.vue-recycle-scroller__item-wrapper) {
min-width: var(--dbx-object-grid-min-width, 0px);
}
.object-browser-scroller :deep(.vue-recycle-scroller__item-view) {
@@ -0,0 +1,41 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("../ObjectBrowser.vue", import.meta.url), "utf8");
// Regression test for #8885: when the object-browser columns are wider than the
// panel, the vertical scrollbar must stay pinned to the visible right edge
// instead of travelling with the content. The list scroller therefore owns the
// horizontal scrolling (with the content width applied to its inner item
// wrapper), while the header is clipped and aligned programmatically.
describe("ObjectBrowser vertical scrollbar viewport lock", () => {
it("keeps the outer table container from scrolling horizontally", () => {
const tableMatch = /class="object-browser-table[^"]*"/.exec(source);
expect(tableMatch).not.toBeNull();
expect(tableMatch![0]).toContain("overflow-hidden");
expect(tableMatch![0]).not.toContain("overflow-x-auto");
});
it("moves the content min-width onto the scroller's inner wrapper", () => {
const scrollerMatch = /<RecycleScroller[^>]*listScrollerRef[^>]*>/.exec(source);
expect(scrollerMatch).not.toBeNull();
expect(scrollerMatch![0]).toContain("--dbx-object-grid-min-width");
expect(scrollerMatch![0]).not.toContain("minWidth:");
expect(source).toContain(".object-browser-scroller {");
expect(source).toContain("overflow-x: auto;");
expect(source).toContain("min-width: var(--dbx-object-grid-min-width, 0px);");
});
it("clips the header and syncs its scroll position with the scroller", () => {
const headerMatch = /ref="objectListHeaderRef"[^>]*/.exec(source);
expect(headerMatch).not.toBeNull();
expect(headerMatch![0]).toContain("overflow-hidden");
expect(source).toContain("function syncObjectListHeaderScroll(");
expect(source).toContain("header.scrollLeft = el.scrollLeft");
// The sync runs on every scroller scroll event, when the scroller (re)attaches,
// and whenever the column widths change the content width.
const scrollHandler = source.indexOf("function onObjectsScroll(");
expect(source.slice(scrollHandler, scrollHandler + 300)).toContain("syncObjectListHeaderScroll()");
expect(source).toContain("nextTick(() => syncObjectListHeaderScroll())");
});
});