fix(grid): anchor toolbar overlay menus to their trigger buttons

This commit is contained in:
zipg authored and GitHub committed 2026-09-30 12:23:01 +08:00
1 parent 0f958aca36
commit 44064a0eea
5 files changed
+83 -6

No files matched your search

+13 -2
View File
@@ -2095,6 +2095,17 @@ const goToColumnOpen = ref(false);
const goToColumnSearch = ref("");
const goToColumnSearchInput = ref<HTMLInputElement>();
const goToColumnListRef = ref<HTMLElement>();
// The trigger lives inside a Tooltip so the icon keeps its hover hint, and that
// tooltip claims the popper anchor for its own popper root. Pointing the popover at
// the button element directly keeps the column list positioned on screen.
const goToColumnTriggerRef = ref<HTMLElement | { $el?: HTMLElement }>();
function goToColumnTriggerElement(): HTMLElement | undefined {
const trigger = goToColumnTriggerRef.value;
if (!trigger) return undefined;
return trigger instanceof HTMLElement ? trigger : trigger.$el;
}
const goToColumnSelectedIndex = ref(0);
const columnOrderKeys = computed(() => uniqueDataGridColumnOrderKeys(props.result.columns, props.sourceColumns));
const resolvedColumnLayoutScopeKey = computed(
@@ -12556,7 +12567,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
<Tooltip>
<TooltipTrigger as-child>
<PopoverTrigger as-child>
<Button data-toolbar-action="navigation" variant="ghost" size="sm" :class="['data-grid-topbar-action-button h-5 shrink-0 text-xs px-1.5', compact ? 'data-grid-topbar-action-button--compact' : '', goToColumnOpen ? 'text-primary bg-primary/10' : '']">
<Button ref="goToColumnTriggerRef" data-toolbar-action="navigation" variant="ghost" size="sm" :class="['data-grid-topbar-action-button h-5 shrink-0 text-xs px-1.5', compact ? 'data-grid-topbar-action-button--compact' : '', goToColumnOpen ? 'text-primary bg-primary/10' : '']">
<Columns3 class="data-grid-topbar-action-icon w-3 h-3" />
<span
class="data-grid-topbar-action-label"
@@ -12570,7 +12581,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
</TooltipTrigger>
<TooltipContent side="bottom">{{ t("grid.goToColumn") }}</TooltipContent>
</Tooltip>
<PopoverContent align="end" class="w-56 p-2" @keydown="onGoToColumnKeydown">
<PopoverContent :reference="goToColumnTriggerElement()" align="end" class="w-56 p-2" @keydown="onGoToColumnKeydown">
<div class="relative mb-1">
<Search class="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<input ref="goToColumnSearchInput" v-model="goToColumnSearch" :placeholder="t('grid.searchColumn')" class="h-8 w-full rounded-md border bg-transparent pl-7 pr-6 text-xs outline-none focus-visible:border-ring/50 focus-visible:ring-1 focus-visible:ring-ring/25" />
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from "vue";
import { computed, ref } from "vue";
import { Check, ChevronDown, Copy, Eye, Loader2, Map, Plus, RefreshCcw, RotateCcw, Rows3, Save, TableProperties, Trash2, Upload } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
@@ -46,6 +46,18 @@ const props = defineProps<{
}>();
const autoRefreshIntervals = computed(() => (props.autoRefresh ? dataGridToolbarIntervalOptions(props.autoRefresh.intervalOptions, props.autoRefresh.intervalSeconds) : []));
// Each dropdown is wrapped in a Tooltip so its button keeps a hover hint, but a
// TooltipTrigger claims the popper anchor for its own popper root. Pointing the
// menu at the button element directly keeps the surface positioned on screen.
const autoRefreshTriggerRef = ref<HTMLElement | { $el?: HTMLElement }>();
const exportTriggerRef = ref<HTMLElement | { $el?: HTMLElement }>();
function toolbarTriggerElement(trigger: HTMLElement | { $el?: HTMLElement } | undefined): HTMLElement | undefined {
if (!trigger) return undefined;
return trigger instanceof HTMLElement ? trigger : trigger.$el;
}
const visibleActionOrder = computed<DataGridToolbarActionKey[]>(() => {
const visibility: Record<DataGridToolbarActionKey, boolean> = {
refresh: isDataGridToolbarCapabilityVisible(props.refresh),
@@ -98,6 +110,7 @@ function actionLabelClass(action: DataGridToolbarActionKey) {
<TooltipTrigger as-child>
<DropdownMenuTrigger as-child>
<Button
ref="autoRefreshTriggerRef"
variant="ghost"
size="sm"
data-toolbar-action="autoRefresh"
@@ -113,7 +126,7 @@ function actionLabelClass(action: DataGridToolbarActionKey) {
</TooltipTrigger>
<TooltipContent side="bottom">{{ autoRefresh?.label ?? autoRefresh?.shortLabel }}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" class="w-40">
<DropdownMenuContent :reference="toolbarTriggerElement(autoRefreshTriggerRef)" align="end" class="w-40">
<DropdownMenuItem class="gap-2" :disabled="autoRefresh?.disabled" @select="void toggleDataGridToolbarAutoRefresh(autoRefresh)">
<Check v-if="autoRefresh?.enabled" class="h-3.5 w-3.5" />
<span v-else class="h-3.5 w-3.5" />
@@ -200,7 +213,7 @@ function actionLabelClass(action: DataGridToolbarActionKey) {
<Tooltip>
<TooltipTrigger as-child>
<DropdownMenuTrigger as-child>
<Button data-toolbar-action="exportData" variant="ghost" size="sm" :class="actionButtonClass('exportData')" :disabled="exportData?.disabled || !exportData?.items.length">
<Button ref="exportTriggerRef" data-toolbar-action="exportData" variant="ghost" size="sm" :class="actionButtonClass('exportData')" :disabled="exportData?.disabled || !exportData?.items.length">
<Upload class="data-grid-topbar-action-icon h-3 w-3" />
<span class="data-grid-topbar-action-label" :class="actionLabelClass('exportData')">{{ exportData?.label }}</span>
</Button>
@@ -208,7 +221,7 @@ function actionLabelClass(action: DataGridToolbarActionKey) {
</TooltipTrigger>
<TooltipContent side="bottom">{{ exportData?.label }}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" class="min-w-52">
<DropdownMenuContent :reference="toolbarTriggerElement(exportTriggerRef)" align="end" class="min-w-52">
<template v-for="item in exportData?.items ?? []" :key="item.value">
<DropdownMenuSeparator v-if="item.separatorBefore" />
<DropdownMenuItem :disabled="item.disabled" @select="void selectDataGridToolbarExportItem(exportData, item.value)">
@@ -85,6 +85,11 @@ function mountToolbar() {
onToggle: vi.fn(),
onSelectInterval: vi.fn(),
},
exportData: {
label: "Export",
items: [{ value: "csv", label: "CSV" }],
onSelect: vi.fn(),
},
}),
);
}
@@ -147,6 +152,21 @@ async function hover(element: HTMLElement) {
await settle();
}
async function click(element: HTMLElement) {
element.click();
await settle();
}
function expectPositionedSurface(contentType: string) {
const content = document.querySelector<HTMLElement>(`[data-slot="${contentType}"]`);
if (!content) throw new Error(`No ${contentType} surface was rendered`);
const wrapper = content.parentElement;
if (!wrapper) throw new Error(`No ${contentType} popper wrapper was rendered`);
// Reka parks an unpositioned surface at `translate(0, -200%)`, which puts every
// menu and popover above the viewport.
expect(wrapper.style.transform).toMatch(/^translate\(-?[\d.]+px, -?[\d.]+px\)$/);
}
afterEach(() => {
vi.restoreAllMocks();
for (const { app, host } of mountedApps.splice(0)) {
@@ -187,3 +207,36 @@ describe("data grid icon-only toolbar tooltips", () => {
expect(showsTooltip("Go to column")).toBe(true);
});
});
// Reka resolves the popper anchor from the nearest popper root. Wrapping an overlay
// trigger in TooltipTrigger handed the anchor to the tooltip root instead, so the
// menu/popover still opened but kept the unpositioned `translate(0, -200%)`
// fallback and every toolbar click looked like a no-op.
describe("data grid toolbar overlay surfaces anchor to their trigger", () => {
it("positions the auto refresh menu after a click", async () => {
const { host } = mountToolbar();
await settle();
await click(toolbarButton(host, "autoRefresh"));
expectPositionedSurface("dropdown-menu-content");
});
it("positions the export menu after a click", async () => {
const { host } = mountToolbar();
await settle();
await click(toolbarButton(host, "exportData"));
expectPositionedSurface("dropdown-menu-content");
});
it("positions the go to column popover after a click", async () => {
const { host } = mountGrid();
await settle();
await click(toolbarButton(host, "navigation"));
expectPositionedSurface("popover-content");
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB