fix(editor): improve result navigation and scrolling performance
This commit is contained in:
17 files changed
+392
-36
No files matched your search
@@ -18,6 +18,7 @@ import { completionMatchRanges } from "@/lib/common/completionMatch";
|
||||
import { executionCandidateForMode, resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget";
|
||||
import { buildExecutionCandidates, hasMultipleExecutionTargets, supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
|
||||
import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt as executableStatementRangeStartingAtLine, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache";
|
||||
import { createDeferredEditorTask } from "@/lib/editor/deferredEditorTask";
|
||||
import { currentStatementFrameRangeTo } from "@/lib/sql/currentStatementFrame";
|
||||
import { looksLikeDmlStatement } from "@/lib/sql/dmlChangePreview";
|
||||
import { expandToSqlStatementWindow } from "@/lib/sql/insertValueHints";
|
||||
@@ -247,7 +248,7 @@ const emit = defineEmits<{
|
||||
openObjectSource: [target: SqlObjectNavigationTarget, initialEditing: boolean];
|
||||
clickColumn: [columns: Array<{ name: string; table: string; schema?: string }>, error?: string | undefined];
|
||||
closeColumnPanel: [];
|
||||
viewportChange: [viewport: { scrollTop: number; scrollLeft: number }];
|
||||
viewportChange: [viewport: { scrollTop: number; scrollLeft: number }, tabId?: string];
|
||||
selectionStateChange: [selection: { anchor: number; head: number }];
|
||||
editorStateFlushed: [];
|
||||
sendSelectionToAi: [sql: string];
|
||||
@@ -257,7 +258,10 @@ const editorRef = ref<HTMLDivElement>();
|
||||
const view = shallowRef<EditorViewType | null>(null);
|
||||
const contextMenuOpen = ref(false);
|
||||
let contextMenuPointerCleanup: (() => void) | null = null;
|
||||
let viewportEmitFrame: number | null = null;
|
||||
let viewportOwnerTabId = props.tabId;
|
||||
const viewportEmitTask = createDeferredEditorTask(() => {
|
||||
if (latestViewport) emitEditorViewport(latestViewport);
|
||||
}, 150);
|
||||
let viewportRestoreFrame: number | null = null;
|
||||
let latestViewport: { scrollTop: number; scrollLeft: number } | undefined = props.initialViewport;
|
||||
let lastEmittedViewport: { scrollTop: number; scrollLeft: number } | undefined = props.initialViewport;
|
||||
@@ -6058,7 +6062,9 @@ onMounted(async () => {
|
||||
ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: import("@codemirror/view").DecorationSet;
|
||||
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private refreshTask = createDeferredEditorTask(() => {
|
||||
if (this.currentView.dom.isConnected) this.currentView.dispatch({ effects: refreshSqlSemanticHighlightEffect.of(null) });
|
||||
}, SQL_SEMANTIC_HIGHLIGHT_DEBOUNCE_MS);
|
||||
private cachedDoc: import("@codemirror/state").Text | null = null;
|
||||
private cachedSql = "";
|
||||
private cachedDialectId = "";
|
||||
@@ -6069,7 +6075,7 @@ onMounted(async () => {
|
||||
spans: Array<{ start: number; end: number }>;
|
||||
}> = [];
|
||||
|
||||
constructor(currentView: import("@codemirror/view").EditorView) {
|
||||
constructor(private currentView: import("@codemirror/view").EditorView) {
|
||||
this.decorations = this.buildDecorations(currentView);
|
||||
}
|
||||
|
||||
@@ -6080,24 +6086,23 @@ onMounted(async () => {
|
||||
this.scheduleRefresh(update.view);
|
||||
return;
|
||||
}
|
||||
if (refreshRequested || update.viewportChanged) {
|
||||
if (refreshRequested) {
|
||||
this.cancelRefresh();
|
||||
this.decorations = this.buildDecorations(update.view);
|
||||
} else if (update.viewportChanged) {
|
||||
// Keep existing decorations while scrolling. Parse only after the
|
||||
// viewport settles instead of blocking CodeMirror's layout update.
|
||||
this.scheduleRefresh(update.view);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleRefresh(currentView: import("@codemirror/view").EditorView) {
|
||||
if (this.refreshTimer !== null) return;
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
this.refreshTimer = null;
|
||||
if (currentView.dom.isConnected) currentView.dispatch({ effects: refreshSqlSemanticHighlightEffect.of(null) });
|
||||
}, SQL_SEMANTIC_HIGHLIGHT_DEBOUNCE_MS);
|
||||
this.currentView = currentView;
|
||||
this.refreshTask.schedule();
|
||||
}
|
||||
|
||||
cancelRefresh() {
|
||||
if (this.refreshTimer === null) return;
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
this.refreshTask.cancel();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
@@ -6841,6 +6846,12 @@ function swapEditorDocument(doc: string) {
|
||||
function activateTabDocument(prevTabId: string | undefined, tabId: string | undefined, doc: string) {
|
||||
const currentView = view.value;
|
||||
if (!currentView) return;
|
||||
// Flush the outgoing document before props and restored scroll positions
|
||||
// become the new tab's state. The event carries its original owner.
|
||||
flushEditorViewport();
|
||||
viewportOwnerTabId = tabId;
|
||||
latestViewport = props.initialViewport ?? { scrollTop: 0, scrollLeft: 0 };
|
||||
lastEmittedViewport = undefined;
|
||||
clearScheduledPreviewContextRefresh();
|
||||
if (prevTabId !== undefined) {
|
||||
tabStateCache.set(prevTabId, currentView.state);
|
||||
@@ -7168,10 +7179,7 @@ onDeactivated(pauseQueryEditorBackgroundWork);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
pauseQueryEditorBackgroundWork();
|
||||
if (viewportEmitFrame !== null) {
|
||||
cancelAnimationFrame(viewportEmitFrame);
|
||||
viewportEmitFrame = null;
|
||||
}
|
||||
viewportEmitTask.cancel();
|
||||
if (viewportRestoreFrame !== null) {
|
||||
cancelAnimationFrame(viewportRestoreFrame);
|
||||
viewportRestoreFrame = null;
|
||||
@@ -7244,25 +7252,19 @@ function restoreEditorFocus() {
|
||||
function emitEditorViewport(viewport: { scrollTop: number; scrollLeft: number }) {
|
||||
if (sameEditorViewport(lastEmittedViewport, viewport)) return;
|
||||
lastEmittedViewport = { ...viewport };
|
||||
emit("viewportChange", viewport);
|
||||
emit("viewportChange", viewport, viewportOwnerTabId);
|
||||
}
|
||||
|
||||
function scheduleEditorViewportEmit() {
|
||||
if (!view.value || !editorIsActive) return;
|
||||
latestViewport = readEditorViewport(view.value);
|
||||
scheduleSemanticDiagnostics(700, { preserveOutsideRanges: true });
|
||||
if (viewportEmitFrame !== null) return;
|
||||
viewportEmitFrame = requestAnimationFrame(() => {
|
||||
viewportEmitFrame = null;
|
||||
if (latestViewport) emitEditorViewport(latestViewport);
|
||||
});
|
||||
viewportEmitTask.schedule();
|
||||
}
|
||||
|
||||
function flushEditorViewport() {
|
||||
if (viewportEmitFrame !== null) {
|
||||
cancelAnimationFrame(viewportEmitFrame);
|
||||
viewportEmitFrame = null;
|
||||
}
|
||||
if (view.value) latestViewport = readEditorViewport(view.value);
|
||||
viewportEmitTask.flush();
|
||||
if (latestViewport) emitEditorViewport(latestViewport);
|
||||
}
|
||||
|
||||
@@ -7405,6 +7407,33 @@ defineExpose({
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
[data-query-editor-root] > :deep(.cm-editor) {
|
||||
/* The host supplies both dimensions. Keep viewport DOM changes from
|
||||
invalidating intrinsic sizes throughout the surrounding flex layout;
|
||||
WebKit otherwise spends a frame recomputing it on each viewport change.
|
||||
Leave paint uncontained so editor overlays retain their overflow. */
|
||||
contain: size layout style;
|
||||
}
|
||||
|
||||
[data-query-editor-root] :deep(.cm-scroller::-webkit-scrollbar) {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
[data-query-editor-root] :deep(.cm-scroller::-webkit-scrollbar-track) {
|
||||
background: rgba(127, 127, 127, 0.1);
|
||||
}
|
||||
|
||||
[data-query-editor-root] :deep(.cm-scroller::-webkit-scrollbar-thumb) {
|
||||
background: rgba(127, 127, 127, 0.7);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@supports not selector(::-webkit-scrollbar) {
|
||||
[data-query-editor-root] :deep(.cm-scroller) {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
}
|
||||
|
||||
.query-editor--table-navigation-hover :deep(.cm-content),
|
||||
.query-editor--table-navigation-hover :deep(.cm-line) {
|
||||
cursor: pointer;
|
||||
|
||||
@@ -61,6 +61,7 @@ import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
|
||||
import QueryErrorActions from "@/components/common/QueryErrorActions.vue";
|
||||
import QueryMessagesView from "@/components/layout/QueryMessagesView.vue";
|
||||
import QueryResultToolbarActions from "@/components/layout/QueryResultToolbarActions.vue";
|
||||
import ResultSetNavigator from "@/components/layout/ResultSetNavigator.vue";
|
||||
import QueryResultViewSwitcher from "@/components/layout/QueryResultViewSwitcher.vue";
|
||||
import DataGridCopyFormatControl from "@/components/grid/DataGridCopyFormatControl.vue";
|
||||
import DataGridFontFamilyControl from "@/components/grid/DataGridFontFamilyControl.vue";
|
||||
@@ -1312,7 +1313,7 @@ defineExpose({
|
||||
@send-selection-to-ai="emit('sendSelectionToAi', activeTab.id, $event)"
|
||||
@cursor-change="emit('editorCursorChange', activeTab.id, $event)"
|
||||
@preview-changes-available="emit('previewChangesAvailable', activeTab.id, $event)"
|
||||
@viewport-change="emit('editorViewportChange', activeTab.id, $event)"
|
||||
@viewport-change="(viewport, tabId) => emit('editorViewportChange', tabId ?? activeTab.id, viewport)"
|
||||
@selection-state-change="emit('editorSelectionStateChange', activeTab.id, $event)"
|
||||
@editor-state-flushed="emit('editorStateFlushed', activeTab.id)"
|
||||
@format-error="emit('formatError', activeTab.id)"
|
||||
@@ -1444,13 +1445,7 @@ defineExpose({
|
||||
</div>
|
||||
<div v-else-if="resultRuns.length > 0" class="min-w-0 flex-1" />
|
||||
<span v-if="resultRuns.length > 0 && visibleResultItems.length > 0" class="mx-1 h-4 w-px shrink-0 bg-border" />
|
||||
<div v-if="visibleResultItems.length > 0" data-result-set-tabs-region role="group" :aria-label="t('tabs.resultSets')" class="flex h-full min-w-0 items-center gap-1" :class="resultRuns.length > 0 ? 'shrink-0' : 'flex-1 overflow-x-auto'">
|
||||
<LightTooltip v-for="item in visibleResultItems" :key="item.index" :text="item.label || item.title || t('tabs.resultN', { n: item.n })" :disabled="!item.labelTruncated && !(!item.label && item.title)" :delay="150" :close-delay="0" nowrap>
|
||||
<Button size="sm" :variant="activeOutputView === 'result' && (activeTab.activeResultIndex ?? 0) === item.index ? 'default' : 'ghost'" class="h-6 min-w-0 max-w-48 shrink-0 px-2 text-xs" :aria-label="item.label || t('tabs.resultN', { n: item.n })" @click="selectResultItem(item)">
|
||||
<span class="block min-w-0 max-w-44 whitespace-nowrap">{{ item.displayLabel || item.label || t("tabs.resultN", { n: item.n }) }}</span>
|
||||
</Button>
|
||||
</LightTooltip>
|
||||
</div>
|
||||
<ResultSetNavigator v-if="visibleResultItems.length > 0" :key="`${activeTab.id}:${activeTab.activeResultRunId ?? 'current'}`" :items="visibleResultItems" :active-index="activeTab.activeResultIndex ?? 0" :active="activeOutputView === 'result'" @select="selectResultItem" />
|
||||
</template>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-1">
|
||||
<Popover v-if="activeOutputView === 'result' && activeTab.result && hasTabularResult && !activeElasticsearchJsonResponse" v-model:open="dataGridViewOptionsOpen">
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Check, ChevronDown } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
import { useTabScroll } from "@/composables/useTabScroll";
|
||||
import type { tabularResultItems } from "@/lib/tabs/tabPresentation";
|
||||
|
||||
type ResultItem = ReturnType<typeof tabularResultItems>[number];
|
||||
const props = defineProps<{ items: ResultItem[]; activeIndex: number; active: boolean }>();
|
||||
const emit = defineEmits<{ select: [item: ResultItem] }>();
|
||||
const { t } = useI18n();
|
||||
const scroller = ref<HTMLElement | null>(null);
|
||||
const list = ref<HTMLElement | null>(null);
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const open = ref(false);
|
||||
const search = ref("");
|
||||
const { hasTabOverflow, scrollThumbLeftPercent, scrollThumbWidthPercent, isScrollbarDragging, updateScrollButtons, onTabsWheel, startScrollbarDrag } = useTabScroll(scroller);
|
||||
const thumbStyle = computed(() => ({ left: `${scrollThumbLeftPercent.value}%`, width: `${scrollThumbWidthPercent.value}%` }));
|
||||
const filteredItems = computed(() => {
|
||||
const query = search.value.trim().toLocaleLowerCase();
|
||||
if (!query) return props.items;
|
||||
// A number is an exact result ordinal, not a substring of every SQL statement.
|
||||
if (/^\d+$/.test(query)) return props.items.filter((item) => item.n === Number(query));
|
||||
return props.items.filter((item) => [item.label, item.title, item.result.sourceStatement, t("tabs.resultN", { n: item.n })].some((value) => value?.toLocaleLowerCase().includes(query)));
|
||||
});
|
||||
|
||||
function revealActive() {
|
||||
const container = scroller.value;
|
||||
const button = container?.querySelector<HTMLElement>('[data-active="true"]');
|
||||
if (container && button) {
|
||||
// Scroll only the strip; scrollIntoView can also move the surrounding result pane.
|
||||
const viewport = container.getBoundingClientRect();
|
||||
const bounds = button.getBoundingClientRect();
|
||||
if (bounds.left < viewport.left) container.scrollLeft -= viewport.left - bounds.left;
|
||||
else if (bounds.right > viewport.right) container.scrollLeft += bounds.right - viewport.right;
|
||||
}
|
||||
updateScrollButtons();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.items, props.activeIndex, props.active],
|
||||
() => nextTick(revealActive),
|
||||
{ flush: "post", immediate: true },
|
||||
);
|
||||
watch(open, () => {
|
||||
search.value = "";
|
||||
});
|
||||
|
||||
function select(item: ResultItem) {
|
||||
emit("select", item);
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function focusListItem(index: number) {
|
||||
const buttons = list.value?.querySelectorAll<HTMLButtonElement>("button");
|
||||
if (!buttons?.length) return;
|
||||
buttons[Math.max(0, Math.min(index, buttons.length - 1))]?.focus();
|
||||
}
|
||||
|
||||
function onSearchKeydown(event: KeyboardEvent) {
|
||||
if (event.isComposing) return;
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
focusListItem(event.key === "ArrowDown" ? 0 : filteredItems.value.length - 1);
|
||||
} else if (event.key === "Enter" && filteredItems.value[0]) {
|
||||
event.preventDefault();
|
||||
select(filteredItems.value[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function onListKeydown(event: KeyboardEvent, index: number) {
|
||||
if (event.key === "ArrowUp" && index === 0) {
|
||||
event.preventDefault();
|
||||
searchInput.value?.focus();
|
||||
} else if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
focusListItem(event.key === "Home" ? 0 : event.key === "End" ? filteredItems.value.length - 1 : index + (event.key === "ArrowDown" ? 1 : -1));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-result-set-tabs-region role="group" :aria-label="t('tabs.resultSets')" class="flex h-full min-w-0 flex-1 items-center gap-1 overflow-hidden">
|
||||
<div class="relative h-full min-w-0 flex-1">
|
||||
<div ref="scroller" class="result-set-scroll flex h-full items-center gap-1 overflow-x-auto overflow-y-hidden px-1" @scroll="updateScrollButtons" @wheel="onTabsWheel">
|
||||
<LightTooltip v-for="item in items" :key="item.index" :text="item.label || item.title || t('tabs.resultN', { n: item.n })" :delay="150" :close-delay="0" nowrap>
|
||||
<Button size="sm" :variant="active && activeIndex === item.index ? 'default' : 'ghost'" class="h-6 max-w-48 shrink-0 px-2 text-xs" :data-active="active && activeIndex === item.index ? 'true' : undefined" :aria-pressed="active && activeIndex === item.index" @click="select(item)">
|
||||
<span class="truncate">{{ item.displayLabel || item.label || t("tabs.resultN", { n: item.n }) }}</span>
|
||||
</Button>
|
||||
</LightTooltip>
|
||||
</div>
|
||||
<div v-if="hasTabOverflow" class="result-set-scrollbar" :class="{ dragging: isScrollbarDragging }" @pointerdown="startScrollbarDrag">
|
||||
<div class="result-set-scrollbar-thumb" :style="thumbStyle" />
|
||||
</div>
|
||||
</div>
|
||||
<Popover v-if="items.length > 1" v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-6 shrink-0 gap-1 px-2 text-xs" :title="t('tabs.allResults', { count: items.length })">
|
||||
{{ t("tabs.allResults", { count: items.length }) }}
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" class="w-96 max-h-[var(--reka-popover-content-available-height)] max-w-[calc(100vw-2rem)] gap-1 p-1" @open-auto-focus.prevent="searchInput?.focus()">
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="search"
|
||||
type="search"
|
||||
class="m-1 shrink-0 rounded-md border bg-background px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:placeholder="t('tabs.searchResults')"
|
||||
:aria-label="t('tabs.searchResults')"
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
<div ref="list" class="min-h-0 max-h-72 overflow-y-auto overscroll-contain" :aria-label="t('tabs.resultSets')">
|
||||
<button
|
||||
v-for="(item, index) in filteredItems"
|
||||
:key="item.index"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
|
||||
:aria-pressed="active && activeIndex === item.index"
|
||||
@click="select(item)"
|
||||
@keydown="onListKeydown($event, index)"
|
||||
>
|
||||
<Check class="h-3.5 w-3.5 shrink-0" :class="{ invisible: !active || activeIndex !== item.index }" />
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate font-medium"
|
||||
>{{ t("tabs.resultN", { n: item.n }) }}<template v-if="item.label"> · {{ item.label }}</template></span
|
||||
>
|
||||
<span v-if="item.title" class="block truncate text-muted-foreground">{{ item.title }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<p v-if="!filteredItems.length" role="status" class="p-4 text-center text-xs text-muted-foreground">{{ t("tabs.noMatchingResults") }}</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.result-set-scroll {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.result-set-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.result-set-scrollbar {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
height: 8px;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
}
|
||||
.result-set-scrollbar-thumb {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: var(--foreground);
|
||||
opacity: 0.38;
|
||||
}
|
||||
.result-set-scrollbar:hover .result-set-scrollbar-thumb,
|
||||
.dragging .result-set-scrollbar-thumb {
|
||||
top: 1px;
|
||||
height: 6px;
|
||||
opacity: 0.58;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, nextTick, type App } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import ResultSetNavigator from "../ResultSetNavigator.vue";
|
||||
import { tabularResultItems } from "@/lib/tabs/tabPresentation";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
let app: App | undefined;
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
async function mountNavigator() {
|
||||
// Include a non-tabular result so ordinal and storage index are different.
|
||||
const results = [{ columns: [], rows: [] }, ...Array.from({ length: 100 }, (_, i) => ({ columns: ["value"], rows: [[i + 1]], sourceStatement: `SELECT ${i + 1} AS value` }))] as QueryResult[];
|
||||
const select = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
app = createApp(ResultSetNavigator, { items: tabularResultItems(results), activeIndex: 1, active: true, onSelect: select });
|
||||
app.use(createI18n({ legacy: false, locale: "en", messages: { en: { tabs: { resultN: "Result {n}", resultSets: "Result sets", allResults: "All results ({count})", searchResults: "Search", noMatchingResults: "No matches" } } } }));
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
return { container, select };
|
||||
}
|
||||
|
||||
describe("large result-set navigation", () => {
|
||||
it("scrolls the result-set strip with a vertical wheel and consumes boundary gestures", async () => {
|
||||
const { container } = await mountNavigator();
|
||||
const strip = container.querySelector<HTMLElement>(".result-set-scroll")!;
|
||||
Object.defineProperties(strip, { clientWidth: { value: 400 }, scrollWidth: { value: 8000 } });
|
||||
const wheel = new WheelEvent("wheel", { deltaY: 200, bubbles: true, cancelable: true });
|
||||
strip.dispatchEvent(wheel);
|
||||
expect(strip.scrollLeft).toBe(200);
|
||||
expect(wheel.defaultPrevented).toBe(true);
|
||||
strip.scrollLeft = 7600;
|
||||
const boundary = new WheelEvent("wheel", { deltaY: 200, bubbles: true, cancelable: true });
|
||||
strip.dispatchEvent(boundary);
|
||||
expect(strip.scrollLeft).toBe(7600);
|
||||
expect(boundary.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("finds an exact ordinal among 100 results and selects its original storage index", async () => {
|
||||
const { container, select } = await mountNavigator();
|
||||
const trigger = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("All results"))!;
|
||||
trigger.click();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
const input = document.querySelector<HTMLInputElement>('input[type="search"]')!;
|
||||
expect(input).toBeTruthy();
|
||||
input.value = "8";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await nextTick();
|
||||
const popup = document.querySelector('[data-slot="popover-content"]')!;
|
||||
expect(popup.querySelectorAll("button")).toHaveLength(1);
|
||||
expect(popup.textContent).toContain("Result 8");
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
expect(select).toHaveBeenCalledOnce();
|
||||
expect(select.mock.calls[0]![0]).toMatchObject({ n: 8, index: 8 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user