fix(editor): scope column tooltips and autocomplete

This commit is contained in:
zipg authored and GitHub committed 2026-08-12 00:10:06 +08:00
1 parent ca303e5553
commit b8f4386a6b
5 files changed
+93 -52

No files matched your search

@@ -58,65 +58,65 @@ const emit = defineEmits<{
</script>
<template>
<LightTooltip :text="name" side="bottom" :side-offset="4" :disabled="tooltipDisabled">
<div
class="data-grid-header-cell shrink-0 px-2 py-1.5 border-r border-border whitespace-nowrap hover:bg-gray-200 dark:hover:bg-gray-800 select-none relative overflow-hidden"
:class="[dark && 'data-grid-header-cell--dark', selected && 'data-grid-header-cell--selected', searchMatch && 'bg-amber-500/20 ring-1 ring-inset ring-amber-500/40', frozen && 'data-grid-header-cell--frozen', frozenSeparator && 'data-grid-header-cell--frozen-separator', dragClass]"
:style="columnStyle"
:data-grid-column-index="actualColumnIndex"
:data-visible-col-index="visibleColumnIndex"
@pointerdown="emit('pointerdown', $event)"
@click.capture="emit('clickCapture', $event)"
@click="emit('click', $event)"
@contextmenu="emit('contextmenu', $event)"
>
<span class="flex min-w-0 items-center gap-1 overflow-hidden">
<!-- 索引标识:显示在列名左侧 -->
<KeyRound v-if="columnIndexKind === 'primary'" class="h-3 w-3 shrink-0" :class="columnIndexColorClass(columnIndexKind)" :title="columnIndexText(columnIndexKind)" />
<Hash v-else-if="columnIndexKind && columnIndexKind !== 'none'" class="h-3 w-3 shrink-0" :class="columnIndexColorClass(columnIndexKind)" :title="columnIndexText(columnIndexKind)" />
<span class="flex min-w-0 flex-1 flex-col overflow-hidden">
<div
class="data-grid-header-cell shrink-0 px-2 py-1.5 border-r border-border whitespace-nowrap hover:bg-gray-200 dark:hover:bg-gray-800 select-none relative overflow-hidden"
:class="[dark && 'data-grid-header-cell--dark', selected && 'data-grid-header-cell--selected', searchMatch && 'bg-amber-500/20 ring-1 ring-inset ring-amber-500/40', frozen && 'data-grid-header-cell--frozen', frozenSeparator && 'data-grid-header-cell--frozen-separator', dragClass]"
:style="columnStyle"
:data-grid-column-index="actualColumnIndex"
:data-visible-col-index="visibleColumnIndex"
@pointerdown="emit('pointerdown', $event)"
@click.capture="emit('clickCapture', $event)"
@click="emit('click', $event)"
@contextmenu="emit('contextmenu', $event)"
>
<span class="flex min-w-0 items-center gap-1 overflow-hidden">
<!-- 索引标识:显示在列名左侧 -->
<KeyRound v-if="columnIndexKind === 'primary'" class="h-3 w-3 shrink-0" :class="columnIndexColorClass(columnIndexKind)" :title="columnIndexText(columnIndexKind)" />
<Hash v-else-if="columnIndexKind && columnIndexKind !== 'none'" class="h-3 w-3 shrink-0" :class="columnIndexColorClass(columnIndexKind)" :title="columnIndexText(columnIndexKind)" />
<LightTooltip :text="name" side="bottom" :side-offset="4" :disabled="tooltipDisabled">
<span data-column-tooltip-trigger class="flex min-w-0 flex-1 flex-col overflow-hidden">
<span class="min-w-0 truncate leading-4">{{ name }}</span>
<span v-if="showTypeLine" data-grid-header-type-line class="h-3 min-w-0 truncate text-[10px] font-normal leading-3" :class="[typeClass, { invisible: !columnType }]" :title="columnType || undefined" :aria-hidden="columnType ? undefined : true">{{ columnType }}</span>
<span v-if="showCommentLine" data-grid-header-comment-line class="h-3 min-w-0 truncate text-[10px] font-normal leading-3 text-muted-foreground" :class="{ invisible: !columnComment }" :title="columnComment || undefined" :aria-hidden="columnComment ? undefined : true">{{
columnComment
}}</span>
</span>
<slot name="actions" />
</span>
<div data-column-resize-handle class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30" @mousedown.stop="emit('resizeStart', $event)" @click.stop.prevent @dblclick.stop="emit('autoFit')" />
</div>
<template #content>
<div class="grid min-w-56 grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-1 px-3 py-2">
<span class="text-background/70">{{ columnNameLabel }}</span>
<span class="flex min-w-0 items-center gap-2">
<span class="min-w-0 flex-1 truncate font-mono">{{ name }}</span>
<button type="button" class="flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-background/10" :title="copyColumnNameLabel" @click.stop="emit('copyName')">
<Copy class="h-3 w-3" />
</button>
</span>
<template v-if="tooltipColumnType ?? columnType">
<span class="text-background/70">{{ columnTypeLabel }}</span>
<span :class="typeClass">{{ tooltipColumnType ?? columnType }}</span>
<template #content>
<div class="grid min-w-56 grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-1 px-3 py-2">
<span class="text-background/70">{{ columnNameLabel }}</span>
<span class="flex min-w-0 items-center gap-2">
<span class="min-w-0 flex-1 truncate font-mono">{{ name }}</span>
<button type="button" class="flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-background/10" :title="copyColumnNameLabel" @click.stop="emit('copyName')">
<Copy class="h-3 w-3" />
</button>
</span>
<template v-if="tooltipColumnType ?? columnType">
<span class="text-background/70">{{ columnTypeLabel }}</span>
<span :class="typeClass">{{ tooltipColumnType ?? columnType }}</span>
</template>
<template v-if="tooltipColumnComment ?? columnComment">
<span class="text-background/70">{{ columnCommentLabel }}</span>
<span>{{ tooltipColumnComment ?? columnComment }}</span>
</template>
<template v-if="columnNullability">
<span class="text-background/70">{{ nullableLabel }}</span>
<span>{{ columnNullability === "nullable" ? yesLabel : noLabel }}</span>
</template>
<template v-if="columnIndexKind && columnIndexKind !== 'none'">
<span class="text-background/70">{{ columnIndexLabel }}</span>
<span class="flex items-center gap-1">
<KeyRound v-if="columnIndexKind === 'primary'" class="h-3 w-3" :class="columnIndexColorClass(columnIndexKind)" />
<Hash v-else class="h-3 w-3" :class="columnIndexColorClass(columnIndexKind)" />
<span :class="columnIndexColorClass(columnIndexKind)">{{ columnIndexText(columnIndexKind) }}</span>
</span>
</template>
</div>
</template>
<template v-if="tooltipColumnComment ?? columnComment">
<span class="text-background/70">{{ columnCommentLabel }}</span>
<span>{{ tooltipColumnComment ?? columnComment }}</span>
</template>
<template v-if="columnNullability">
<span class="text-background/70">{{ nullableLabel }}</span>
<span>{{ columnNullability === "nullable" ? yesLabel : noLabel }}</span>
</template>
<template v-if="columnIndexKind && columnIndexKind !== 'none'">
<span class="text-background/70">{{ columnIndexLabel }}</span>
<span class="flex items-center gap-1">
<KeyRound v-if="columnIndexKind === 'primary'" class="h-3 w-3" :class="columnIndexColorClass(columnIndexKind)" />
<Hash v-else class="h-3 w-3" :class="columnIndexColorClass(columnIndexKind)" />
<span :class="columnIndexColorClass(columnIndexKind)">{{ columnIndexText(columnIndexKind) }}</span>
</span>
</template>
</div>
</template>
</LightTooltip>
</LightTooltip>
<span data-column-header-actions class="contents"><slot name="actions" /></span>
</span>
<div data-column-resize-handle class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30" @mousedown.stop="emit('resizeStart', $event)" @click.stop.prevent @dblclick.stop="emit('autoFit')" />
</div>
</template>
<style scoped>
@@ -53,6 +53,7 @@ const expandedHeight = ref(56);
const suggestionPosition = ref({ left: 0, top: 0, width: 180 });
const historyPreview = ref<{ value: string; left: number; top: number; maxWidth: number; arrowTop: number; side: "left" | "right" } | null>(null);
const pointerMovedSuggestionIndex = ref(-1);
const editorFocused = ref(false);
let collapseTimer: ReturnType<typeof setTimeout> | undefined;
let resizeObserver: ResizeObserver | undefined;
let expandAfterComposition = false;
@@ -67,6 +68,7 @@ const editor = useDataGridConditionEditor({
historyScope: () => props.historyScope,
suggestionProvider: props.suggestionProvider,
suggestionDebounceMs: props.suggestionDebounceMs,
suggestionsEnabled: editorFocused,
});
const activeEditor = computed(() => overlayRef.value ?? inputRef.value);
@@ -316,6 +318,7 @@ function syncSelection(target: HTMLTextAreaElement) {
}
function onFocus(event: FocusEvent) {
editorFocused.value = true;
syncSelection(event.currentTarget as HTMLTextAreaElement);
resizeEditor(true);
}
@@ -334,6 +337,8 @@ function scheduleCollapse() {
collapseTimer = setTimeout(() => {
const active = document.activeElement;
if (active === inputRef.value || active === overlayRef.value) return;
editorFocused.value = false;
editor.dismiss();
expanded.value = false;
}, 0);
}
@@ -62,6 +62,20 @@ afterEach(() => {
});
describe("DataGridConditionEditor quote completion", () => {
it("does not open suggestions for a programmatic value update while unfocused", async () => {
const { value, input } = mountEditor("where", "", { columns: ["status", "started_at"] });
value.value = "sta";
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(document.querySelector('[role="listbox"]')).toBeNull();
input.focus();
input.setSelectionRange(3, 3);
input.dispatchEvent(new Event("select", { bubbles: true }));
await vi.waitFor(() => expect(document.querySelectorAll('[role="option"]')).toHaveLength(2));
});
it("inserts paired quotes in WHERE and places the caret between them", async () => {
const { value, input } = mountEditor("where", "id = ");
input.focus();
@@ -291,6 +291,26 @@ describe("DataGridPagination", () => {
});
describe("DataGridColumnHeader", () => {
it("limits the metadata tooltip trigger to the column text block", () => {
const mounted = mountComponent(DataGridColumnHeader, {
name: "status",
actualColumnIndex: 0,
visibleColumnIndex: 0,
copyColumnNameLabel: "copy",
columnNameLabel: "name",
columnTypeLabel: "type",
columnCommentLabel: "comment",
});
const tooltip = findOne(mounted.root, (node) => node.props["data-stub"] === "LightTooltip");
const trigger = findOne(mounted.root, (node) => node.props["data-column-tooltip-trigger"] === "");
const actions = findOne(mounted.root, (node) => node.props["data-column-header-actions"] === "");
const resizeHandle = findOne(mounted.root, (node) => node.props["data-column-resize-handle"] === "");
expect(trigger.parent).toBe(tooltip);
expect(actions.parent).not.toBe(tooltip);
expect(resizeHandle.parent).not.toBe(tooltip);
});
it("cancels resize-handle clicks without leaking header click events", () => {
const click = vi.fn();
const clickCapture = vi.fn();
@@ -45,6 +45,7 @@ export interface UseDataGridConditionEditorOptions {
suggestionProvider?: DataGridConditionSuggestionProvider;
suggestionDebounceMs?: number;
suggestionLimit?: number;
suggestionsEnabled?: MaybeRefOrGetter<boolean>;
}
const WHERE_TOKEN_PATTERN = /([^\s,()><=!&|]+)$/;
@@ -299,6 +300,7 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp
suggestions.value = [];
highlightedIndex.value = -1;
historyOpen.value = false;
if (options.suggestionsEnabled !== undefined && !toValue(options.suggestionsEnabled)) return;
if (!value.trim()) return;
const target = conditionCompletionTarget(options.kind, value, selectionStart, selectionEnd, toValue(options.identifierQuote));