fix(grid): anchor toolbar overlay menus to their trigger buttons
This commit is contained in:
5 files changed
+83
-6
No files matched your search
@@ -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)">
|
||||
|
||||
+53
@@ -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 |
Reference in new issue
Block a user