fix(editor): scope column tooltips and autocomplete
This commit is contained in:
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));
|
||||
|
||||
Reference in new issue
Block a user