mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 10:28:44 +00:00
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:
@@ -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) {
|
||||
|
||||
Vendored
+41
@@ -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())");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user