feat(ui): progressively shrink data grid toolbar

This commit is contained in:
zipg
2026-09-04 17:10:50 +08:00
committed by GitHub
parent f861776c3c
commit 6e9e8d1818
15 changed files with 188 additions and 67 deletions
+43 -21
View File
@@ -354,7 +354,9 @@ import { resolveGridFocusRestoreTarget } from "@/lib/dataGrid/dataGridFocusResto
import { buildOrderedGridRows, type GridInsertRowPosition, type GridNewRowPlacement } from "@/lib/dataGrid/gridNewRowPlacement";
import {
DATA_GRID_CONDITION_TOOLBAR_MIN_WIDTH,
DATA_GRID_TOOLBAR_ACTION_COLLAPSE_ORDER,
dataGridDeleteRowToolbarState,
dataGridToolbarActionCollapseCount,
isDataGridToolbarCompact,
type DataGridReloadIntent,
type DataGridToolbarActionCapability,
@@ -712,6 +714,7 @@ const columnCommentMap = computed(() => {
const dataGridTopbarWidth = ref(0);
const dataGridViewportWidth = ref(0);
const dataGridTopbarOverflowCompact = ref(false);
const dataGridTopbarOverflowActionCount = ref(0);
const dataGridTopbarExpandedRequiredWidth = ref(0);
const showColumnCommentsInHeader = computed(() => settingsStore.editorSettings.showColumnCommentsInHeader);
const showColumnTypesInHeader = computed(() => settingsStore.editorSettings.showColumnTypesInHeader);
@@ -733,6 +736,8 @@ const compactColumnHeaderActions = computed(() => settingsStore.editorSettings.c
const dataGridRenderMode = computed(() => settingsStore.editorSettings.dataGridRenderMode);
const dataGridSearchMode = computed(() => settingsStore.editorSettings.dataGridSearchMode);
const compactDataGridToolbar = computed(() => dataGridTopbarOverflowCompact.value || isDataGridToolbarCompact(dataGridTopbarWidth.value, dataGridViewportWidth.value, DATA_GRID_CONDITION_TOOLBAR_MIN_WIDTH));
const responsiveDataGridToolbarActionCount = computed(() => dataGridToolbarActionCollapseCount(dataGridTopbarWidth.value, dataGridViewportWidth.value, DATA_GRID_CONDITION_TOOLBAR_MIN_WIDTH));
const compactDataGridToolbarActionCount = computed(() => Math.max(responsiveDataGridToolbarActionCount.value, dataGridTopbarOverflowActionCount.value));
const infiniteScrollEnabled = computed(() => props.paginationEnabled && settingsStore.editorSettings.infiniteScroll);
const queryResultMaxRows = computed(() => effectiveQueryResultMaxRows(settingsStore.editorSettings.queryResultMaxRowsEnabled, settingsStore.editorSettings.queryResultMaxRows));
const paginationMaxRows = computed(() => (isResultsContext.value ? queryResultMaxRows.value : undefined));
@@ -2561,6 +2566,8 @@ let gridScrollbarsRuntime: DataGridScrollbarsRuntime;
let dataGridTopbarResizeObserver: ResizeObserver | null = null;
let dataGridTopbarMutationObserver: MutationObserver | null = null;
let dataGridTopbarRecheckTimer: ReturnType<typeof setTimeout> | null = null;
const DATA_GRID_TOPBAR_RECHECK_DELAY_MS = 360;
const DATA_GRID_TOPBAR_EXPAND_HYSTERESIS_PX = 16;
let cellEditResizeObserver: ResizeObserver | null = null;
// vue-virtual-scroller's @resize only fires in pageMode (it observes window),
// so in container-scroll mode (transpose uses RecycleScroller without pageMode)
@@ -3210,43 +3217,57 @@ function observeGridHorizontalScrollbarScroller() {
function updateDataGridTopbarWidth() {
const topbar = dataGridTopbarRef.value;
const previousActionCount = compactDataGridToolbarActionCount.value;
dataGridTopbarWidth.value = topbar?.clientWidth ?? 0;
dataGridViewportWidth.value = typeof window === "undefined" ? 0 : window.innerWidth;
if (!topbar) return;
const fixedBreakpointCompact = isDataGridToolbarCompact(dataGridTopbarWidth.value, dataGridViewportWidth.value, DATA_GRID_CONDITION_TOOLBAR_MIN_WIDTH);
if (fixedBreakpointCompact) return;
if (dataGridTopbarOverflowCompact.value) {
if (dataGridTopbarWidth.value >= dataGridTopbarExpandedRequiredWidth.value) {
dataGridTopbarOverflowCompact.value = false;
dataGridTopbarExpandedRequiredWidth.value = 0;
}
if (compactDataGridToolbarActionCount.value !== previousActionCount) {
scheduleDataGridTopbarRecheck();
return;
}
if (dataGridTopbarOverflowCompact.value || dataGridTopbarOverflowActionCount.value > 0) {
if (dataGridTopbarWidth.value >= dataGridTopbarExpandedRequiredWidth.value) {
dataGridTopbarOverflowCompact.value = false;
dataGridTopbarOverflowActionCount.value = 0;
dataGridTopbarExpandedRequiredWidth.value = 0;
scheduleDataGridTopbarRecheck();
return;
}
}
// The action list gains preview/save/rollback controls in editable grids. The
// fixed breakpoint cannot see that extra width, so capture the full layout
// width before the overflow-clip wrapper can hide the trailing action.
// responsive breakpoint cannot see that extra width, so progressively
// compact the leftmost controls before overflow-clip hides a trailing action.
if (topbar.scrollWidth > topbar.clientWidth + 1) {
dataGridTopbarExpandedRequiredWidth.value = topbar.scrollWidth;
dataGridTopbarOverflowCompact.value = true;
dataGridTopbarExpandedRequiredWidth.value = Math.max(dataGridTopbarExpandedRequiredWidth.value, topbar.scrollWidth + DATA_GRID_TOPBAR_EXPAND_HYSTERESIS_PX);
if (!compactDataGridToolbar.value) {
dataGridTopbarOverflowCompact.value = true;
} else if (compactDataGridToolbarActionCount.value < DATA_GRID_TOOLBAR_ACTION_COLLAPSE_ORDER.length) {
dataGridTopbarOverflowActionCount.value = compactDataGridToolbarActionCount.value + 1;
} else {
return;
}
scheduleDataGridTopbarRecheck();
}
}
function resetDataGridTopbarOverflowCompact() {
if (!dataGridTopbarOverflowCompact.value) return;
if (!dataGridTopbarOverflowCompact.value && dataGridTopbarOverflowActionCount.value === 0) return;
dataGridTopbarOverflowCompact.value = false;
dataGridTopbarOverflowActionCount.value = 0;
dataGridTopbarExpandedRequiredWidth.value = 0;
scheduleDataGridTopbarRecheck();
}
function scheduleDataGridTopbarRecheck() {
if (dataGridTopbarRecheckTimer) clearTimeout(dataGridTopbarRecheckTimer);
// Wait for the label-width transition to finish before measuring the newly
// shortened action list. If it still overflows, updateDataGridTopbarWidth
// captures its new, smaller expanded width.
dataGridTopbarRecheckTimer = setTimeout(() => {
dataGridTopbarRecheckTimer = null;
updateDataGridTopbarWidth();
}, 360);
}, DATA_GRID_TOPBAR_RECHECK_DELAY_MS);
}
function clearDataGridTopbarRecheckTimer() {
@@ -12676,7 +12697,8 @@ function openGridSnapshot() {
<DataGridToolbar
class="ml-auto"
:compact="compactDataGridToolbar"
:compact-action-count="compactDataGridToolbarActionCount"
:navigation-visible="props.result.columns.length > 0"
:refresh="refreshToolbarCapability"
:auto-refresh="autoRefreshToolbarCapability"
:add-row="addRowToolbarCapability"
@@ -12733,17 +12755,17 @@ function openGridSnapshot() {
</Tooltip>
</template>
<template #navigation>
<template #navigation="{ compact }">
<Tooltip v-if="props.result.columns.length">
<TooltipTrigger as-child>
<Popover v-model:open="goToColumnOpen">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" :class="['data-grid-topbar-action-button h-5 shrink-0 text-xs px-1.5', compactDataGridToolbar ? 'data-grid-topbar-action-button--compact' : '', goToColumnOpen ? 'text-primary bg-primary/10' : '']">
<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' : '']">
<Columns3 class="data-grid-topbar-action-icon w-3 h-3" />
<span
class="data-grid-topbar-action-label"
:class="{
'data-grid-topbar-action-label--compact': compactDataGridToolbar,
'data-grid-topbar-action-label--compact': compact,
}"
>{{ t("grid.goToColumn") }}</span
>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch, type CSSProperties } from "vue";
import { Check, Columns3, GripVertical, Search } from "@lucide/vue";
import { Check, GripVertical, ListChecks, Search } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -306,7 +306,7 @@ onBeforeUnmount(resetColumnDragState);
:title="t('grid.columnVisibility')"
:aria-label="t('grid.columnVisibility')"
>
<Columns3 class="h-3.5 w-3.5" />
<ListChecks class="h-3.5 w-3.5" />
<span v-if="!compact">{{ t("grid.columnVisibility") }}</span>
<span v-if="!compact && (grid?.hiddenColumnCount ?? 0) > 0" class="tabular-nums"> {{ grid?.visibleColumnCount }}/{{ grid?.displayableColumnCount }} </span>
</Button>
@@ -5,7 +5,9 @@ import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
DATA_GRID_TOOLBAR_ACTION_COLLAPSE_ORDER,
dataGridToolbarIntervalOptions,
isDataGridToolbarActionCompact,
isDataGridToolbarCapabilityDisabled,
isDataGridToolbarCapabilityVisible,
selectDataGridToolbarAddRowItem,
@@ -16,6 +18,7 @@ import {
triggerDataGridToolbarCopy,
triggerDataGridToolbarAction,
type DataGridToolbarActionCapability,
type DataGridToolbarActionKey,
type DataGridToolbarAddRowCapability,
type DataGridToolbarAutoRefreshCapability,
type DataGridToolbarCopyCapability,
@@ -25,6 +28,8 @@ import {
const props = defineProps<{
compact?: boolean;
compactActionCount?: number;
navigationVisible?: boolean;
refresh: DataGridToolbarActionCapability;
autoRefresh?: DataGridToolbarAutoRefreshCapability;
addRow?: DataGridToolbarAddRowCapability;
@@ -39,11 +44,36 @@ const props = defineProps<{
rollback?: DataGridToolbarActionCapability;
}>();
const actionButtonClass = computed(() => ["data-grid-topbar-action-button h-5 shrink-0 px-1.5 text-xs", props.compact ? "data-grid-topbar-action-button--compact" : ""]);
const autoRefreshIntervals = computed(() => (props.autoRefresh ? dataGridToolbarIntervalOptions(props.autoRefresh.intervalOptions, props.autoRefresh.intervalSeconds) : []));
const visibleActionOrder = computed<DataGridToolbarActionKey[]>(() => {
const visibility: Record<DataGridToolbarActionKey, boolean> = {
refresh: isDataGridToolbarCapabilityVisible(props.refresh),
autoRefresh: isDataGridToolbarCapabilityVisible(props.autoRefresh),
navigation: props.navigationVisible === true,
copyData: isDataGridToolbarCapabilityVisible(props.copyData),
addRow: isDataGridToolbarCapabilityVisible(props.addRow),
deleteRow: isDataGridToolbarCapabilityVisible(props.deleteRow),
exportData: isDataGridToolbarCapabilityVisible(props.exportData),
transpose: isDataGridToolbarCapabilityVisible(props.transpose),
tableInfo: isDataGridToolbarCapabilityVisible(props.tableInfo),
layerPreview: isDataGridToolbarCapabilityVisible(props.layerPreview),
preview: isDataGridToolbarCapabilityVisible(props.preview),
save: isDataGridToolbarCapabilityVisible(props.save),
rollback: isDataGridToolbarCapabilityVisible(props.rollback),
};
return DATA_GRID_TOOLBAR_ACTION_COLLAPSE_ORDER.filter((action) => visibility[action]);
});
function actionLabelClass() {
return { "data-grid-topbar-action-label--compact": props.compact };
function actionIsCompact(action: DataGridToolbarActionKey): boolean {
return isDataGridToolbarActionCompact(action, visibleActionOrder.value, props.compactActionCount ?? 0, props.compact === true);
}
function actionButtonClass(action: DataGridToolbarActionKey) {
return ["data-grid-topbar-action-button h-5 shrink-0 px-1.5 text-xs", actionIsCompact(action) ? "data-grid-topbar-action-button--compact" : ""];
}
function actionLabelClass(action: DataGridToolbarActionKey) {
return { "data-grid-topbar-action-label--compact": actionIsCompact(action) };
}
</script>
@@ -53,10 +83,10 @@ function actionLabelClass() {
<Tooltip v-if="isDataGridToolbarCapabilityVisible(refresh)">
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" :class="actionButtonClass" :disabled="isDataGridToolbarCapabilityDisabled(refresh)" @click="void triggerDataGridToolbarAction(refresh)">
<Button data-toolbar-action="refresh" variant="ghost" size="sm" :class="actionButtonClass('refresh')" :disabled="isDataGridToolbarCapabilityDisabled(refresh)" @click="void triggerDataGridToolbarAction(refresh)">
<Loader2 v-if="refresh.loading" class="data-grid-topbar-action-icon h-3 w-3 animate-spin" />
<RefreshCcw v-else class="data-grid-topbar-action-icon h-3 w-3" />
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ refresh.label }}</span>
<span class="data-grid-topbar-action-label" :class="actionLabelClass('refresh')">{{ refresh.label }}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{{ refresh.tooltip ?? refresh.label }}</TooltipContent>
@@ -67,14 +97,15 @@ function actionLabelClass() {
<Button
variant="ghost"
size="sm"
:class="[...actionButtonClass, autoRefresh?.enabled ? 'bg-primary/10 text-primary hover:bg-primary/15' : 'text-muted-foreground hover:text-foreground']"
data-toolbar-action="autoRefresh"
:class="[...actionButtonClass('autoRefresh'), autoRefresh?.enabled ? 'bg-primary/10 text-primary hover:bg-primary/15' : 'text-muted-foreground hover:text-foreground']"
:disabled="isDataGridToolbarCapabilityDisabled(autoRefresh)"
:title="autoRefresh?.label"
:aria-label="autoRefresh?.label"
:aria-pressed="autoRefresh?.enabled"
>
<Timer class="data-grid-topbar-action-icon h-3 w-3" />
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ autoRefresh?.enabled ? `${autoRefresh.intervalSeconds}s` : autoRefresh?.shortLabel }}</span>
<span class="data-grid-topbar-action-label" :class="actionLabelClass('autoRefresh')">{{ autoRefresh?.enabled ? `${autoRefresh.intervalSeconds}s` : autoRefresh?.shortLabel }}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-40">
@@ -91,14 +122,14 @@ function actionLabelClass() {
</DropdownMenuContent>
</DropdownMenu>
<slot name="navigation" />
<slot name="navigation" :compact="actionIsCompact('navigation')" />
<div v-if="isDataGridToolbarCapabilityVisible(copyData)" class="flex h-5 shrink-0 items-stretch overflow-hidden rounded-md border border-border">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-5 rounded-none border-0 px-1.5 text-xs" :disabled="copyData?.disabled" @click="void triggerDataGridToolbarCopy(copyData)">
<Copy class="h-3 w-3" :class="compact ? '' : 'mr-1'" />
<span v-if="!compact">{{ copyData?.label }}</span>
<Button data-toolbar-action="copyData" variant="ghost" size="sm" :class="[...actionButtonClass('copyData'), 'rounded-none border-0']" :disabled="copyData?.disabled" @click="void triggerDataGridToolbarCopy(copyData)">
<Copy class="data-grid-topbar-action-icon h-3 w-3" />
<span class="data-grid-topbar-action-label" :class="actionLabelClass('copyData')">{{ copyData?.label }}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{{ copyData?.tooltip ?? copyData?.label }}</TooltipContent>
@@ -124,9 +155,9 @@ function actionLabelClass() {
<div v-if="isDataGridToolbarCapabilityVisible(addRow)" class="flex h-5 shrink-0 items-stretch overflow-hidden rounded-md border border-border">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-5 rounded-none border-0 px-1.5 text-xs" :disabled="isDataGridToolbarCapabilityDisabled(addRow)" @click="void triggerDataGridToolbarAction(addRow)">
<Plus class="h-3 w-3" :class="compact ? '' : 'mr-1'" />
<span v-if="!compact">{{ addRow?.label }}</span>
<Button data-toolbar-action="addRow" variant="ghost" size="sm" :class="[...actionButtonClass('addRow'), 'rounded-none border-0']" :disabled="isDataGridToolbarCapabilityDisabled(addRow)" @click="void triggerDataGridToolbarAction(addRow)">
<Plus class="data-grid-topbar-action-icon h-3 w-3" />
<span class="data-grid-topbar-action-label" :class="actionLabelClass('addRow')">{{ addRow?.label }}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{{ addRow?.tooltip ?? addRow?.label }}</TooltipContent>
@@ -152,9 +183,9 @@ function actionLabelClass() {
<Tooltip v-if="isDataGridToolbarCapabilityVisible(deleteRow)">
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" :class="actionButtonClass" :disabled="isDataGridToolbarCapabilityDisabled(deleteRow)" @click="void triggerDataGridToolbarAction(deleteRow)">
<Button data-toolbar-action="deleteRow" variant="ghost" size="sm" :class="actionButtonClass('deleteRow')" :disabled="isDataGridToolbarCapabilityDisabled(deleteRow)" @click="void triggerDataGridToolbarAction(deleteRow)">
<Trash2 class="data-grid-topbar-action-icon h-3 w-3" />
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ deleteRow?.label }}</span>
<span class="data-grid-topbar-action-label" :class="actionLabelClass('deleteRow')">{{ deleteRow?.label }}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{{ deleteRow?.tooltip ?? deleteRow?.label }}</TooltipContent>
@@ -164,9 +195,9 @@ function actionLabelClass() {
<Tooltip>
<TooltipTrigger as-child>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" :class="actionButtonClass" :disabled="exportData?.disabled || !exportData?.items.length">
<Button data-toolbar-action="exportData" variant="ghost" size="sm" :class="actionButtonClass('exportData')" :disabled="exportData?.disabled || !exportData?.items.length">
<Download class="data-grid-topbar-action-icon h-3 w-3" />
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ exportData?.label }}</span>
<span class="data-grid-topbar-action-label" :class="actionLabelClass('exportData')">{{ exportData?.label }}</span>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
@@ -184,9 +215,17 @@ function actionLabelClass() {
<Tooltip v-if="isDataGridToolbarCapabilityVisible(transpose)">
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" :class="[...actionButtonClass, transpose?.active ? 'bg-primary/10 text-primary hover:bg-primary/15' : '']" :disabled="isDataGridToolbarCapabilityDisabled(transpose)" :aria-pressed="transpose?.active" @click="void triggerDataGridToolbarAction(transpose)">
<Button
data-toolbar-action="transpose"
variant="ghost"
size="sm"
:class="[...actionButtonClass('transpose'), transpose?.active ? 'bg-primary/10 text-primary hover:bg-primary/15' : '']"
:disabled="isDataGridToolbarCapabilityDisabled(transpose)"
:aria-pressed="transpose?.active"
@click="void triggerDataGridToolbarAction(transpose)"
>
<Rows3 class="data-grid-topbar-action-icon h-3 w-3" />
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ transpose?.label }}</span>
<span class="data-grid-topbar-action-label" :class="actionLabelClass('transpose')">{{ transpose?.label }}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{{ transpose?.tooltip ?? transpose?.label }}</TooltipContent>
@@ -194,9 +233,17 @@ function actionLabelClass() {
<Tooltip v-if="isDataGridToolbarCapabilityVisible(tableInfo)">
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" :class="[...actionButtonClass, tableInfo?.active ? 'bg-primary/10 text-primary hover:bg-primary/15' : '']" :disabled="isDataGridToolbarCapabilityDisabled(tableInfo)" :aria-pressed="tableInfo?.active" @click="void triggerDataGridToolbarAction(tableInfo)">
<Button
data-toolbar-action="tableInfo"
variant="ghost"
size="sm"
:class="[...actionButtonClass('tableInfo'), tableInfo?.active ? 'bg-primary/10 text-primary hover:bg-primary/15' : '']"
:disabled="isDataGridToolbarCapabilityDisabled(tableInfo)"
:aria-pressed="tableInfo?.active"
@click="void triggerDataGridToolbarAction(tableInfo)"
>
<TableProperties class="data-grid-topbar-action-icon h-3 w-3" />
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ tableInfo?.label }}</span>
<span class="data-grid-topbar-action-label" :class="actionLabelClass('tableInfo')">{{ tableInfo?.label }}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{{ tableInfo?.tooltip ?? tableInfo?.label }}</TooltipContent>
@@ -204,9 +251,9 @@ function actionLabelClass() {
<Tooltip v-if="isDataGridToolbarCapabilityVisible(layerPreview)">
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" :class="actionButtonClass" :disabled="isDataGridToolbarCapabilityDisabled(layerPreview)" @click="void triggerDataGridToolbarAction(layerPreview)">
<Button data-toolbar-action="layerPreview" variant="ghost" size="sm" :class="actionButtonClass('layerPreview')" :disabled="isDataGridToolbarCapabilityDisabled(layerPreview)" @click="void triggerDataGridToolbarAction(layerPreview)">
<Map class="data-grid-topbar-action-icon h-3 w-3" />
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ layerPreview?.label }}</span>
<span class="data-grid-topbar-action-label" :class="actionLabelClass('layerPreview')">{{ layerPreview?.label }}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{{ layerPreview?.tooltip ?? layerPreview?.label }}</TooltipContent>
@@ -214,10 +261,10 @@ function actionLabelClass() {
<Tooltip v-if="isDataGridToolbarCapabilityVisible(preview)">
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" :class="[...actionButtonClass, 'text-sky-600 hover:bg-sky-500/10 hover:text-sky-700']" :disabled="isDataGridToolbarCapabilityDisabled(preview)" @click="void triggerDataGridToolbarAction(preview)">
<Button data-toolbar-action="preview" variant="ghost" size="sm" :class="[...actionButtonClass('preview'), 'text-sky-600 hover:bg-sky-500/10 hover:text-sky-700']" :disabled="isDataGridToolbarCapabilityDisabled(preview)" @click="void triggerDataGridToolbarAction(preview)">
<Loader2 v-if="preview?.loading" class="data-grid-topbar-action-icon h-3 w-3 animate-spin" />
<Eye v-else class="data-grid-topbar-action-icon h-3 w-3" />
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ preview?.label }}</span>
<span class="data-grid-topbar-action-label" :class="actionLabelClass('preview')">{{ preview?.label }}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" class="max-w-sm">{{ preview?.tooltip ?? preview?.label }}</TooltipContent>
@@ -225,20 +272,20 @@ function actionLabelClass() {
<Tooltip v-if="isDataGridToolbarCapabilityVisible(save)">
<TooltipTrigger as-child>
<Button variant="default" size="sm" :class="[...actionButtonClass, 'data-grid-topbar-action-button--commit relative']" :disabled="isDataGridToolbarCapabilityDisabled(save)" @click="void triggerDataGridToolbarAction(save)">
<Button data-toolbar-action="save" variant="default" size="sm" :class="[...actionButtonClass('save'), 'data-grid-topbar-action-button--commit relative']" :disabled="isDataGridToolbarCapabilityDisabled(save)" @click="void triggerDataGridToolbarAction(save)">
<Loader2 v-if="save?.loading" class="data-grid-topbar-action-icon h-3 w-3 animate-spin" />
<Save v-else-if="compact || !save?.pendingCount" class="data-grid-topbar-action-icon h-3 w-3" />
<Save v-else-if="actionIsCompact('save') || !save?.pendingCount" class="data-grid-topbar-action-icon h-3 w-3" />
<span
v-if="save && save.pendingCount > 0"
:class="
compact
actionIsCompact('save')
? 'absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-amber-300 px-1 text-[9px] font-semibold leading-none text-amber-950 shadow-[0_0_0_1px_rgba(120,53,15,0.16)] dark:bg-amber-400'
: 'mr-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-amber-300 px-1 text-[9px] font-semibold leading-none text-amber-950 shadow-[0_0_0_1px_rgba(120,53,15,0.16)] dark:bg-amber-400'
"
>
{{ save.pendingCount }}
</span>
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ save?.label }}</span>
<span class="data-grid-topbar-action-label" :class="actionLabelClass('save')">{{ save?.label }}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" class="max-w-sm"
@@ -248,9 +295,9 @@ function actionLabelClass() {
<Tooltip v-if="isDataGridToolbarCapabilityVisible(rollback)">
<TooltipTrigger as-child>
<Button variant="outline" size="sm" :class="actionButtonClass" :disabled="isDataGridToolbarCapabilityDisabled(rollback)" @click="void triggerDataGridToolbarAction(rollback)">
<Button data-toolbar-action="rollback" variant="outline" size="sm" :class="actionButtonClass('rollback')" :disabled="isDataGridToolbarCapabilityDisabled(rollback)" @click="void triggerDataGridToolbarAction(rollback)">
<RotateCcw class="data-grid-topbar-action-icon h-3 w-3" />
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ rollback?.label }}</span>
<span class="data-grid-topbar-action-label" :class="actionLabelClass('rollback')">{{ rollback?.label }}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{{ rollback?.tooltip ?? rollback?.label }}</TooltipContent>
+1 -1
View File
@@ -1476,7 +1476,7 @@ export default {
startAutoRefresh: "Start auto-refresh",
stopAutoRefresh: "Stop auto-refresh",
missingResultRun: "This result is no longer available",
exportResultArchive: "Save Result Archive",
exportResultArchive: "Save Results",
importResultArchive: "Import Result Archive",
importedResultArchive: "Imported results",
resultArchiveExported: "Results exported",
+1 -1
View File
@@ -1453,7 +1453,7 @@ export default withEnglishFallback({
startAutoRefresh: "Iniciar actualización automática",
stopAutoRefresh: "Detener actualización automática",
missingResultRun: "Este resultado ya no está disponible",
exportResultArchive: "Guardar archivo de resultados",
exportResultArchive: "Guardar resultados",
importResultArchive: "Importar archivo de resultados",
importedResultArchive: "Resultados importados",
resultArchiveExported: "Resultados exportados",
+1 -1
View File
@@ -1451,7 +1451,7 @@ export default withEnglishFallback({
startAutoRefresh: "Avvia aggiornamento automatico",
stopAutoRefresh: "Ferma aggiornamento automatico",
missingResultRun: "Questo risultato non e piu disponibile",
exportResultArchive: "Salva Archivio Risultati",
exportResultArchive: "Salva risultati",
importResultArchive: "Importa Archivio Risultati",
importedResultArchive: "Risultati importati",
resultArchiveExported: "Risultati esportati",
+1 -1
View File
@@ -1470,7 +1470,7 @@ export default withEnglishFallback({
startAutoRefresh: "自動更新を開始",
stopAutoRefresh: "自動更新を停止",
missingResultRun: "この結果は利用できなくなりました",
exportResultArchive: "結果アーカイブを保存",
exportResultArchive: "結果を保存",
importResultArchive: "結果アーカイブをインポート",
importedResultArchive: "結果をインポートしました",
resultArchiveExported: "結果をエクスポートしました",
+1 -1
View File
@@ -1357,7 +1357,7 @@ export default withEnglishFallback({
startAutoRefresh: "자동 새로고침 시작",
stopAutoRefresh: "자동 새로고침 중지",
missingResultRun: "이 결과는 더 이상 사용할 수 없습니다",
exportResultArchive: "결과 아카이브 저장",
exportResultArchive: "결과 저장",
importResultArchive: "결과 아카이브 가져오기",
importedResultArchive: "결과를 가져왔습니다",
resultArchiveExported: "결과를 내보냈습니다",
+1 -1
View File
@@ -1453,7 +1453,7 @@ export default withEnglishFallback({
startAutoRefresh: "Iniciar atualização automática",
stopAutoRefresh: "Parar atualização automática",
missingResultRun: "Este resultado não está mais disponível",
exportResultArchive: "Salvar Arquivo de Resultados",
exportResultArchive: "Salvar resultados",
importResultArchive: "Importar Arquivo de Resultados",
importedResultArchive: "Resultados importados",
resultArchiveExported: "Resultados exportados",
+1 -1
View File
@@ -1476,7 +1476,7 @@ export default withEnglishFallback({
startAutoRefresh: "Otomatik yenilemeyi başlat",
stopAutoRefresh: "Otomatik yenilemeyi durdur",
missingResultRun: "Bu sonuç artık kullanılamıyor",
exportResultArchive: "Sonuç Arşivini Kaydet",
exportResultArchive: "Sonuçları Kaydet",
importResultArchive: "Sonuç Arşivini İçe Aktar",
importedResultArchive: "İçe aktarılan sonuçlar",
resultArchiveExported: "Sonuçlar dışa aktarıldı",
+1 -1
View File
@@ -1400,7 +1400,7 @@ export default withEnglishFallback({
startAutoRefresh: "开始自动刷新",
stopAutoRefresh: "停止自动刷新",
missingResultRun: "此结果已不可用",
exportResultArchive: "保存结果归档",
exportResultArchive: "保存结果",
importResultArchive: "导入结果归档",
importedResultArchive: "导入的结果",
resultArchiveExported: "结果已导出",
+1 -1
View File
@@ -1452,7 +1452,7 @@ export default withEnglishFallback({
startAutoRefresh: "開始自動重新整理",
stopAutoRefresh: "停止自動重新整理",
missingResultRun: "此結果已不再可用",
exportResultArchive: "儲存結果封存",
exportResultArchive: "儲存結果",
importResultArchive: "匯入結果封存",
importedResultArchive: "已匯入的結果",
resultArchiveExported: "結果已匯出",
@@ -1,9 +1,12 @@
import { describe, expect, it, vi } from "vitest";
import {
DATA_GRID_CONDITION_TOOLBAR_MIN_WIDTH,
DATA_GRID_TOOLBAR_ACTION_COLLAPSE_ORDER,
dataGridDeleteRowToolbarState,
dataGridToolbarActionCollapseCount,
dataGridToolbarCompactBreakpoint,
dataGridToolbarIntervalOptions,
isDataGridToolbarActionCompact,
isDataGridToolbarCompact,
selectDataGridToolbarAddRowItem,
selectDataGridToolbarAutoRefreshInterval,
@@ -63,6 +66,29 @@ describe("data grid toolbar capabilities", () => {
expect(isDataGridToolbarCompact(1000, 1100)).toBe(false);
});
it("progressively compacts toolbar actions as the available width shrinks", () => {
expect(dataGridToolbarActionCollapseCount(1050, 1440, DATA_GRID_CONDITION_TOOLBAR_MIN_WIDTH)).toBe(0);
expect(dataGridToolbarActionCollapseCount(1049, 1440, DATA_GRID_CONDITION_TOOLBAR_MIN_WIDTH)).toBe(1);
expect(dataGridToolbarActionCollapseCount(1010, 1440, DATA_GRID_CONDITION_TOOLBAR_MIN_WIDTH)).toBe(1);
expect(dataGridToolbarActionCollapseCount(1009, 1440, DATA_GRID_CONDITION_TOOLBAR_MIN_WIDTH)).toBe(2);
expect(dataGridToolbarActionCollapseCount(0, 1440, DATA_GRID_CONDITION_TOOLBAR_MIN_WIDTH)).toBe(0);
expect(dataGridToolbarActionCollapseCount(100, 1440, DATA_GRID_CONDITION_TOOLBAR_MIN_WIDTH)).toBe(DATA_GRID_TOOLBAR_ACTION_COLLAPSE_ORDER.length);
});
it("compacts visible actions from left to right while skipping unavailable actions", () => {
const visibleActions = ["refresh", "navigation", "addRow", "preview", "save", "rollback"] as const;
expect(DATA_GRID_TOOLBAR_ACTION_COLLAPSE_ORDER.slice(-3)).toEqual(["preview", "save", "rollback"]);
expect(isDataGridToolbarActionCompact("refresh", visibleActions, 3)).toBe(true);
expect(isDataGridToolbarActionCompact("navigation", visibleActions, 3)).toBe(true);
expect(isDataGridToolbarActionCompact("addRow", visibleActions, 3)).toBe(true);
expect(isDataGridToolbarActionCompact("preview", visibleActions, 3)).toBe(false);
expect(isDataGridToolbarActionCompact("save", visibleActions, 3)).toBe(false);
expect(isDataGridToolbarActionCompact("rollback", visibleActions, 3)).toBe(false);
expect(isDataGridToolbarActionCompact("autoRefresh", visibleActions, 3)).toBe(false);
expect(isDataGridToolbarActionCompact("rollback", visibleActions, 0, true)).toBe(true);
});
it("does not invoke hidden or disabled actions", async () => {
const onTrigger = vi.fn();
@@ -1,8 +1,13 @@
const DATA_GRID_COMPACT_TOPBAR_MIN_WIDTH = 900;
const DATA_GRID_COMPACT_TOPBAR_MAX_WIDTH = 1050;
const DATA_GRID_COMPACT_TOPBAR_VIEWPORT_RATIO = 0.75;
const DATA_GRID_TOOLBAR_ACTION_COLLAPSE_STEP = 40;
export const DATA_GRID_CONDITION_TOOLBAR_MIN_WIDTH = DATA_GRID_COMPACT_TOPBAR_MAX_WIDTH;
export const DATA_GRID_TOOLBAR_ACTION_COLLAPSE_ORDER = ["refresh", "autoRefresh", "navigation", "copyData", "addRow", "deleteRow", "exportData", "transpose", "tableInfo", "layerPreview", "preview", "save", "rollback"] as const;
export type DataGridToolbarActionKey = (typeof DATA_GRID_TOOLBAR_ACTION_COLLAPSE_ORDER)[number];
export function dataGridToolbarCompactBreakpoint(viewportWidth: number, minimumWidth = DATA_GRID_COMPACT_TOPBAR_MIN_WIDTH): number {
const normalizedViewportWidth = Number.isFinite(viewportWidth) ? Math.max(0, viewportWidth) : DATA_GRID_COMPACT_TOPBAR_MAX_WIDTH;
const normalizedMinimumWidth = Math.min(DATA_GRID_COMPACT_TOPBAR_MAX_WIDTH, Math.max(DATA_GRID_COMPACT_TOPBAR_MIN_WIDTH, minimumWidth));
@@ -13,6 +18,20 @@ export function isDataGridToolbarCompact(toolbarWidth: number, viewportWidth: nu
return toolbarWidth > 0 && toolbarWidth < dataGridToolbarCompactBreakpoint(viewportWidth, minimumWidth);
}
export function dataGridToolbarActionCollapseCount(toolbarWidth: number, viewportWidth: number, minimumWidth?: number): number {
if (!Number.isFinite(toolbarWidth) || toolbarWidth <= 0) return 0;
const deficit = dataGridToolbarCompactBreakpoint(viewportWidth, minimumWidth) - toolbarWidth;
if (deficit <= 0) return 0;
return Math.min(DATA_GRID_TOOLBAR_ACTION_COLLAPSE_ORDER.length, Math.ceil(deficit / DATA_GRID_TOOLBAR_ACTION_COLLAPSE_STEP));
}
export function isDataGridToolbarActionCompact(action: DataGridToolbarActionKey, visibleActions: readonly DataGridToolbarActionKey[], compactActionCount: number, forceCompact = false): boolean {
if (forceCompact) return true;
const actionIndex = visibleActions.indexOf(action);
if (actionIndex < 0) return false;
return actionIndex < Math.max(0, Math.floor(compactActionCount));
}
export type DataGridReloadIntent = "refresh";
export interface DataGridToolbarActionCapability {
+10 -3
View File
@@ -6,6 +6,7 @@ import { compileScript, compileTemplate, parse } from "vue/compiler-sfc";
const contentAreaPath = "apps/desktop/src/components/layout/ContentArea.vue";
const appPath = "apps/desktop/src/App.vue";
const dataGridPath = "apps/desktop/src/components/grid/DataGrid.vue";
const dataGridToolbarPath = "apps/desktop/src/components/grid/DataGridToolbar.vue";
const viewSwitcherPath = "apps/desktop/src/components/layout/QueryResultViewSwitcher.vue";
const toolbarActionsPath = "apps/desktop/src/components/layout/QueryResultToolbarActions.vue";
@@ -25,7 +26,7 @@ function assertSfcCompiles(path: string): void {
}
test("query result toolbar SFCs compile", () => {
for (const path of [contentAreaPath, dataGridPath, viewSwitcherPath, toolbarActionsPath]) assertSfcCompiles(path);
for (const path of [contentAreaPath, dataGridPath, dataGridToolbarPath, viewSwitcherPath, toolbarActionsPath]) assertSfcCompiles(path);
});
test("ContentArea keeps query-result insert and delete capabilities separate", () => {
@@ -169,14 +170,19 @@ test("standalone result views use the same compact toolbar breakpoint", () => {
assert.match(dataGrid, /isDataGridToolbarCompact\(dataGridTopbarWidth\.value, dataGridViewportWidth\.value, DATA_GRID_CONDITION_TOOLBAR_MIN_WIDTH\)/);
});
test("embedded result toolbar compacts when editing actions overflow its available width", () => {
test("embedded result toolbar progressively compacts when editing actions overflow its available width", () => {
const dataGrid = source(dataGridPath);
const toolbar = source(dataGridToolbarPath);
assert.match(dataGrid, /dataGridTopbarMutationObserver = new MutationObserver\(updateDataGridTopbarWidth\)/);
assert.match(dataGrid, /dataGridTopbarMutationObserver\.observe\(topbar, \{ childList: true, characterData: true, subtree: true \}\)/);
assert.match(dataGrid, /topbar\.scrollWidth > topbar\.clientWidth \+ 1/);
assert.match(dataGrid, /dataGridTopbarOverflowCompact\.value = true/);
assert.match(dataGrid, /dataGridTopbarOverflowActionCount\.value = compactDataGridToolbarActionCount\.value \+ 1/);
assert.match(dataGrid, /dataGridTopbarWidth\.value >= dataGridTopbarExpandedRequiredWidth\.value/);
assert.match(dataGrid, /:compact-action-count="compactDataGridToolbarActionCount"/);
assert.match(toolbar, /DATA_GRID_TOOLBAR_ACTION_COLLAPSE_ORDER\.filter/);
assert.match(toolbar, /<slot name="navigation" :compact="actionIsCompact\('navigation'\)"/);
});
test("embedded result toolbar remeasures after the SQL preview action is removed", () => {
@@ -184,7 +190,8 @@ test("embedded result toolbar remeasures after the SQL preview action is removed
assert.match(dataGrid, /if \(previousVisible && !visible\) resetDataGridTopbarOverflowCompact\(\)/);
assert.match(dataGrid, /dataGridTopbarOverflowCompact\.value = false/);
assert.match(dataGrid, /dataGridTopbarRecheckTimer = setTimeout\(/);
assert.match(dataGrid, /dataGridTopbarOverflowActionCount\.value = 0/);
assert.match(dataGrid, /function scheduleDataGridTopbarRecheck\(\)/);
assert.match(dataGrid, /clearDataGridTopbarRecheckTimer\(\)/);
});