fix(grid): preserve data tab scroll and search across switches
This commit is contained in:
1 parent
ed29ef9eed
commit
451e50b2fb
11 files changed
+652
-15
No files matched your search
@@ -38,7 +38,7 @@ vi.mock("@/stores/productionSafetyStore", () => ({
|
||||
useProductionSafetyStore: () => ({}),
|
||||
}));
|
||||
|
||||
function createEditor(
|
||||
function createEditorWithResult(
|
||||
sourceColumns?: Array<string | undefined>,
|
||||
confirmDangerousRowDeletion = true,
|
||||
cacheKey?: string,
|
||||
@@ -125,7 +125,33 @@ function createEditor(
|
||||
});
|
||||
|
||||
editor.newRows.value = [[null, null, null]];
|
||||
return editor;
|
||||
return { editor, result };
|
||||
}
|
||||
|
||||
function createEditor(...args: Parameters<typeof createEditorWithResult>) {
|
||||
return createEditorWithResult(...args).editor;
|
||||
}
|
||||
|
||||
function beforeTabSwitchEvent(fromTabId: string, tabId = "next-tab") {
|
||||
return new CustomEvent("dbx:before-tab-switch", { detail: { tabId, fromTabId } });
|
||||
}
|
||||
|
||||
function setupTabSwitchScrollFixture() {
|
||||
class TestScroller {
|
||||
scrollTop = 0;
|
||||
scrollLeft = 0;
|
||||
scrollTo({ top, left }: ScrollToOptions) {
|
||||
if (typeof top === "number") this.scrollTop = top;
|
||||
if (typeof left === "number") this.scrollLeft = left;
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("HTMLElement", TestScroller);
|
||||
const rows: CellValue[][] = [
|
||||
["a", null, 1],
|
||||
["b", null, 2],
|
||||
["c", null, 3],
|
||||
];
|
||||
return { TestScroller, rows };
|
||||
}
|
||||
|
||||
describe("useDataGridEditor result snapshots", () => {
|
||||
@@ -200,6 +226,112 @@ describe("useDataGridEditor result snapshots", () => {
|
||||
expect(remountedScroller.scrollLeft).toBe(0);
|
||||
});
|
||||
|
||||
it("adopts a scroll-only snapshot when the previous instance was torn down by a tab switch (#8524)", () => {
|
||||
const { TestScroller, rows } = setupTabSwitchScrollFixture();
|
||||
const key = "table-tab-8524";
|
||||
const previous = createEditor(undefined, true, key, undefined, rows);
|
||||
previous.newRows.value = [];
|
||||
const previousScroller = new TestScroller();
|
||||
previousScroller.scrollTop = 6_400;
|
||||
previousScroller.scrollLeft = 24;
|
||||
previous.scrollerRef.value = previousScroller as unknown as NonNullable<typeof previous.scrollerRef.value>;
|
||||
previous.onBeforeTabSwitch(beforeTabSwitchEvent(key));
|
||||
// Unmount path runs right after the switch and must not clobber the provenance.
|
||||
previous.savePendingSnapshot(true, true);
|
||||
|
||||
const remounted = createEditor(undefined, true, key, undefined, rows);
|
||||
const remountedScroller = new TestScroller();
|
||||
remounted.scrollerRef.value = remountedScroller as unknown as NonNullable<typeof remounted.scrollerRef.value>;
|
||||
remounted.restorePendingSnapshotFocus();
|
||||
expect(remountedScroller.scrollTop).toBe(6_400);
|
||||
expect(remountedScroller.scrollLeft).toBe(24);
|
||||
});
|
||||
|
||||
it("ignores a tab switch that names a different tab", () => {
|
||||
const { TestScroller, rows } = setupTabSwitchScrollFixture();
|
||||
const key = "table-tab-other-group";
|
||||
const previous = createEditor(undefined, true, key, undefined, rows);
|
||||
previous.newRows.value = [];
|
||||
const previousScroller = new TestScroller();
|
||||
previousScroller.scrollTop = 6_400;
|
||||
previousScroller.scrollLeft = 24;
|
||||
previous.scrollerRef.value = previousScroller as unknown as NonNullable<typeof previous.scrollerRef.value>;
|
||||
// Split groups keep several grids mounted; this one did not change tabs.
|
||||
previous.onBeforeTabSwitch(beforeTabSwitchEvent("some-other-tab"));
|
||||
previous.savePendingSnapshot(true, true);
|
||||
|
||||
const remounted = createEditor(undefined, true, key, undefined, rows);
|
||||
const remountedScroller = new TestScroller();
|
||||
remounted.scrollerRef.value = remountedScroller as unknown as NonNullable<typeof remounted.scrollerRef.value>;
|
||||
remounted.restorePendingSnapshotFocus();
|
||||
expect(remountedScroller.scrollTop).toBe(0);
|
||||
expect(remountedScroller.scrollLeft).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores a tab switch event without a fromTabId", () => {
|
||||
const { TestScroller, rows } = setupTabSwitchScrollFixture();
|
||||
const key = "table-tab-no-origin";
|
||||
const previous = createEditor(undefined, true, key, undefined, rows);
|
||||
previous.newRows.value = [];
|
||||
const previousScroller = new TestScroller();
|
||||
previousScroller.scrollTop = 6_400;
|
||||
previousScroller.scrollLeft = 24;
|
||||
previous.scrollerRef.value = previousScroller as unknown as NonNullable<typeof previous.scrollerRef.value>;
|
||||
previous.onBeforeTabSwitch(new CustomEvent("dbx:before-tab-switch", { detail: { tabId: "next-tab" } }));
|
||||
previous.savePendingSnapshot(true, true);
|
||||
|
||||
const remounted = createEditor(undefined, true, key, undefined, rows);
|
||||
const remountedScroller = new TestScroller();
|
||||
remounted.scrollerRef.value = remountedScroller as unknown as NonNullable<typeof remounted.scrollerRef.value>;
|
||||
remounted.restorePendingSnapshotFocus();
|
||||
expect(remountedScroller.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it("drops the tab-switch scroll once the result identity changes", async () => {
|
||||
const { TestScroller, rows } = setupTabSwitchScrollFixture();
|
||||
const key = "table-tab-reloaded";
|
||||
const { editor: previous, result } = createEditorWithResult(undefined, true, key, undefined, rows);
|
||||
previous.newRows.value = [];
|
||||
const previousScroller = new TestScroller();
|
||||
previousScroller.scrollTop = 6_400;
|
||||
previousScroller.scrollLeft = 24;
|
||||
previous.scrollerRef.value = previousScroller as unknown as NonNullable<typeof previous.scrollerRef.value>;
|
||||
previous.onBeforeTabSwitch(beforeTabSwitchEvent(key));
|
||||
previous.savePendingSnapshot(true, true);
|
||||
|
||||
// A reload lands a new row array: the grid must start at the first row (#7341).
|
||||
result.value = { columns: ["first", "hidden", "last"], rows: rows.map((row) => [...row]) };
|
||||
await nextTick();
|
||||
previous.savePendingSnapshot(true, true);
|
||||
|
||||
const remounted = createEditor(undefined, true, key, undefined, rows);
|
||||
const remountedScroller = new TestScroller();
|
||||
remounted.scrollerRef.value = remountedScroller as unknown as NonNullable<typeof remounted.scrollerRef.value>;
|
||||
remounted.restorePendingSnapshotFocus();
|
||||
expect(remountedScroller.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it("clears the tab-switch provenance when the grid is explicitly scrolled to the top", () => {
|
||||
const { TestScroller, rows } = setupTabSwitchScrollFixture();
|
||||
const key = "table-tab-reset-scroll";
|
||||
const previous = createEditor(undefined, true, key, undefined, rows);
|
||||
previous.newRows.value = [];
|
||||
const previousScroller = new TestScroller();
|
||||
previousScroller.scrollTop = 6_400;
|
||||
previousScroller.scrollLeft = 24;
|
||||
previous.scrollerRef.value = previousScroller as unknown as NonNullable<typeof previous.scrollerRef.value>;
|
||||
previous.onBeforeTabSwitch(beforeTabSwitchEvent(key));
|
||||
// Sort/filter/paginate/refresh all route through here and mean "new viewport".
|
||||
previous.resetGridVerticalScroll(true);
|
||||
previous.savePendingSnapshot(true, true);
|
||||
|
||||
const remounted = createEditor(undefined, true, key, undefined, rows);
|
||||
const remountedScroller = new TestScroller();
|
||||
remounted.scrollerRef.value = remountedScroller as unknown as NonNullable<typeof remounted.scrollerRef.value>;
|
||||
remounted.restorePendingSnapshotFocus();
|
||||
expect(remountedScroller.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it("still adopts the cached scroll when the snapshot carries pending edits", () => {
|
||||
class TestScroller {
|
||||
scrollTop = 0;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { nextTick, ref } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDataGridSearch } from "@/composables/useDataGridSearch";
|
||||
import { clearDataGridSearchStates, createDataGridSearchScopeKey, loadDataGridSearchState, saveDataGridSearchState } from "@/lib/dataGrid/dataGridSearchStatePersistence";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
@@ -123,3 +124,133 @@ describe("useDataGridSearch", () => {
|
||||
expect(search.currentMatchIndex.value).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDataGridSearch persistence (#8524)", () => {
|
||||
const COLUMNS = ["left", "right"];
|
||||
const SCOPE = createDataGridSearchScopeKey(COLUMNS);
|
||||
const ROWS = [
|
||||
["hit", "hit"],
|
||||
["none", "hit"],
|
||||
];
|
||||
|
||||
function createSearch({ onNavigate = vi.fn(), persistenceKey = "tab-1" as string | undefined } = {}) {
|
||||
const search = useDataGridSearch({
|
||||
columns: COLUMNS,
|
||||
rows: ROWS,
|
||||
getCellSearchText: (row: string[], column: number) => row[column],
|
||||
onNavigate,
|
||||
persistenceKey,
|
||||
persistenceScopeKey: () => SCOPE,
|
||||
});
|
||||
return { search, onNavigate };
|
||||
}
|
||||
|
||||
beforeEach(() => clearDataGridSearchStates());
|
||||
|
||||
it("persists the query, overlay and match index as the user searches", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { search } = createSearch();
|
||||
search.overlayVisible.value = true;
|
||||
search.searchText.value = "hit";
|
||||
await flushSearchDebounce();
|
||||
search.navigateMatch(1);
|
||||
await nextTick();
|
||||
|
||||
const saved = loadDataGridSearchState("tab-1", SCOPE);
|
||||
expect(saved).toMatchObject({ searchText: "hit", deferredSearchText: "hit", overlayVisible: true, currentMatchIndex: 1 });
|
||||
});
|
||||
|
||||
it("restores text, overlay and match index without auto-navigating or debouncing", async () => {
|
||||
const { search, onNavigate } = createSearch();
|
||||
saveDataGridSearchState("tab-1", { scopeKey: SCOPE, searchText: "hit", deferredSearchText: "hit", overlayVisible: true, currentMatchIndex: 2 });
|
||||
|
||||
expect(search.restorePersistedState()).toBe(true);
|
||||
// No advanceTimersByTime: the restore bypasses the debounce entirely.
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(search.overlayVisible.value).toBe(true);
|
||||
expect(search.deferredSearchText.value).toBe("hit");
|
||||
expect(search.matchCount.value).toBe(3);
|
||||
expect(search.currentMatchIndex.value).toBe(2);
|
||||
// The grid's own scroll restore owns the viewport when returning to a tab.
|
||||
expect(onNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clamps a stale restored match index", async () => {
|
||||
const { search } = createSearch();
|
||||
saveDataGridSearchState("tab-1", { scopeKey: SCOPE, searchText: "none", deferredSearchText: "none", overlayVisible: true, currentMatchIndex: 9 });
|
||||
|
||||
search.restorePersistedState();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(search.matchCount.value).toBe(1);
|
||||
expect(search.currentMatchIndex.value).toBe(0);
|
||||
});
|
||||
|
||||
it("does not reopen the suggestion popup on restore", async () => {
|
||||
const { search } = createSearch();
|
||||
saveDataGridSearchState("tab-1", { scopeKey: SCOPE, searchText: "le", deferredSearchText: "le", overlayVisible: true, currentMatchIndex: 0 });
|
||||
|
||||
search.restorePersistedState();
|
||||
await nextTick();
|
||||
|
||||
// "le" prefixes the "left" column, so the non-restore path would suggest it.
|
||||
expect(search.suggestions.value).toEqual([]);
|
||||
expect(search.suggestionIndex.value).toBe(-1);
|
||||
});
|
||||
|
||||
it("does not restore across a column signature change", () => {
|
||||
const { search } = createSearch();
|
||||
saveDataGridSearchState("tab-1", { scopeKey: createDataGridSearchScopeKey(["renamed"]), searchText: "hit", deferredSearchText: "hit", overlayVisible: true, currentMatchIndex: 0 });
|
||||
|
||||
expect(search.restorePersistedState()).toBe(false);
|
||||
expect(search.searchText.value).toBe("");
|
||||
});
|
||||
|
||||
it("lets a keystroke during the restore window cancel the token", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { search, onNavigate } = createSearch();
|
||||
saveDataGridSearchState("tab-1", { scopeKey: SCOPE, searchText: "hit", deferredSearchText: "hit", overlayVisible: true, currentMatchIndex: 2 });
|
||||
|
||||
search.restorePersistedState();
|
||||
search.searchText.value = "none";
|
||||
await flushSearchDebounce();
|
||||
|
||||
// Normal typing behaviour resumes: debounced, auto-navigated, index reset.
|
||||
expect(search.deferredSearchText.value).toBe("none");
|
||||
expect(search.currentMatchIndex.value).toBe(0);
|
||||
expect(onNavigate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the persisted entry when the search bar is closed", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { search } = createSearch();
|
||||
search.overlayVisible.value = true;
|
||||
search.searchText.value = "hit";
|
||||
await flushSearchDebounce();
|
||||
expect(loadDataGridSearchState("tab-1", SCOPE)).toBeDefined();
|
||||
|
||||
search.close();
|
||||
await nextTick();
|
||||
expect(loadDataGridSearchState("tab-1", SCOPE)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("opts out entirely when the host passes no persistence key", async () => {
|
||||
vi.useFakeTimers();
|
||||
// Built without the helper: an explicit `undefined` argument would hit its
|
||||
// default and silently re-enable persistence.
|
||||
const search = useDataGridSearch({
|
||||
columns: COLUMNS,
|
||||
rows: ROWS,
|
||||
getCellSearchText: (row: string[], column: number) => row[column],
|
||||
});
|
||||
search.overlayVisible.value = true;
|
||||
search.searchText.value = "hit";
|
||||
await flushSearchDebounce();
|
||||
|
||||
expect(search.restorePersistedState()).toBe(false);
|
||||
expect(loadDataGridSearchState("tab-1", SCOPE)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -141,6 +141,12 @@ interface PendingChangesSnapshot {
|
||||
editValue?: string;
|
||||
transactionActive?: boolean;
|
||||
scroll?: { top: number; left: number };
|
||||
// True only when this snapshot was written because the user navigated away from
|
||||
// this grid's own tab. A scroll-only snapshot may be replayed on remount only
|
||||
// with that provenance (#8524); every other remount reason — refresh,
|
||||
// re-execute, sort, paginate, eviction reload — still starts at the first row
|
||||
// (#7341).
|
||||
scrollFromTabSwitch?: boolean;
|
||||
columnCount: number;
|
||||
rowCount: number;
|
||||
}
|
||||
@@ -308,6 +314,9 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
let draftPromotionScheduled = false;
|
||||
const savingNewRows = new WeakSet<CellValue[]>();
|
||||
let pendingScrollRestore: PendingChangesSnapshot["scroll"] | undefined;
|
||||
// Instance-scoped so it survives the second save: a tab switch saves once from
|
||||
// onBeforeTabSwitch and again from onBeforeUnmount on this same instance.
|
||||
let scrollSnapshotFromTabSwitch = false;
|
||||
let saveScrollSnapshotTimer = 0;
|
||||
let componentActive = true;
|
||||
|
||||
@@ -326,13 +335,14 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
restoredEditingCell = !!cached.editingCell;
|
||||
restoredTransactionActive = cached.transactionActive === true;
|
||||
// A scroll-only snapshot (no pending edits, draft row, or active cell editor)
|
||||
// must not drag a remounted grid back to the previous viewport: the fresh
|
||||
// result should start at the first row (#7341). Scroll is only replayed
|
||||
// alongside edit state so the user lands back on their edited rows. The
|
||||
// KeepAlive activate path keeps pure scroll restore via the in-instance
|
||||
// pendingScrollRestore, which this gate does not touch.
|
||||
// must not drag a remounted grid back to the previous viewport unless we know
|
||||
// why the previous instance went away. Two reasons justify replaying it: the
|
||||
// snapshot carries edit state the user must land back on, or the previous
|
||||
// instance was torn down by a tab switch (#8524) — data tabs render a single
|
||||
// active pane keyed by tab id, so returning to a tab remounts the grid. A
|
||||
// fresh or reloaded result still starts at the first row (#7341).
|
||||
const snapshotHasEditState = cached.newRows.length > 0 || cached.dirtyRows.size > 0 || cached.deletedRows.size > 0 || !!cached.editingCell || !!cached.quickEntryDraftRow;
|
||||
pendingScrollRestore = snapshotHasEditState ? cached.scroll : undefined;
|
||||
pendingScrollRestore = snapshotHasEditState || cached.scrollFromTabSwitch === true ? cached.scroll : undefined;
|
||||
pendingChangesCache.delete(key);
|
||||
} else {
|
||||
pendingChangesCache.delete(key);
|
||||
@@ -510,8 +520,22 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
if (el) el.scrollTop = 0;
|
||||
}
|
||||
|
||||
// Every caller below expresses an explicit "new viewport" intent (sort, filter,
|
||||
// paginate, refresh, result identity change). Drop the tab-switch provenance so a
|
||||
// snapshot written earlier cannot drag the next mount back to the old offset.
|
||||
// The already-written cache entry is fixed up in place because a switch can be
|
||||
// dispatched without completing, and with split groups the global activeTabId
|
||||
// watcher can name a tab belonging to another still-mounted grid.
|
||||
function clearTabSwitchScrollProvenance() {
|
||||
scrollSnapshotFromTabSwitch = false;
|
||||
const k = cacheKey?.value;
|
||||
const cached = k ? pendingChangesCache.get(k) : undefined;
|
||||
if (cached?.scrollFromTabSwitch) cached.scrollFromTabSwitch = false;
|
||||
}
|
||||
|
||||
function resetGridVerticalScroll(afterResult = false) {
|
||||
if (afterResult) resetScrollAfterResult = true;
|
||||
clearTabSwitchScrollProvenance();
|
||||
if (resetScrollFrame) cancelAnimationFrame(resetScrollFrame);
|
||||
scrollGridToTop();
|
||||
nextTick(() => {
|
||||
@@ -2006,6 +2030,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
}
|
||||
previousResultRows = rows;
|
||||
pendingScrollRestore = undefined;
|
||||
clearTabSwitchScrollProvenance();
|
||||
discardChanges();
|
||||
},
|
||||
);
|
||||
@@ -2034,6 +2059,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
editValue: editValue.value,
|
||||
transactionActive: transactionActive.value,
|
||||
scroll,
|
||||
scrollFromTabSwitch: scroll ? scrollSnapshotFromTabSwitch : false,
|
||||
columnCount: result.value.columns.length,
|
||||
rowCount: result.value.rows.length,
|
||||
});
|
||||
@@ -2045,8 +2071,14 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
applyScrollPosition(pendingScrollRestore);
|
||||
}
|
||||
|
||||
function onBeforeTabSwitch() {
|
||||
function onBeforeTabSwitch(event?: Event) {
|
||||
if (!componentActive) return;
|
||||
const detail = (event as CustomEvent<{ tabId?: string; fromTabId?: string }> | undefined)?.detail;
|
||||
const key = cacheKey?.value;
|
||||
// Split editor groups keep several grids mounted at once and every one of them
|
||||
// receives this window event, so only the grid whose own tab is being left may
|
||||
// claim the scroll restore. A missing fromTabId falls through to "start at top".
|
||||
if (key && detail?.fromTabId && cacheKeyBelongsToTab(key, detail.fromTabId)) scrollSnapshotFromTabSwitch = true;
|
||||
savePendingSnapshot(true, true);
|
||||
if (editingCell.value) suppressNextBlurCommit = true;
|
||||
}
|
||||
@@ -2184,6 +2216,9 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
isPreviewLoading,
|
||||
previewChanges,
|
||||
savePendingSnapshot,
|
||||
// Exposed for tests: the window listener is only registered inside a component
|
||||
// instance, so specs drive the tab-switch path directly.
|
||||
onBeforeTabSwitch,
|
||||
restorePendingSnapshotFocus,
|
||||
syncHeaderScroll: (headerRef: Ref<HTMLDivElement | undefined>) => (e: Event) => {
|
||||
if (headerRef.value) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, getCurrentScope, nextTick, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
|
||||
import { dataGridSearchMatchKey } from "@/lib/dataGrid/canvasDataGridRenderer";
|
||||
import { loadDataGridSearchState, saveDataGridSearchState } from "@/lib/dataGrid/dataGridSearchStatePersistence";
|
||||
|
||||
export type DataGridSearchMatch = {
|
||||
kind: "cell" | "column";
|
||||
@@ -18,6 +19,10 @@ export type UseDataGridSearchOptions<Row> = {
|
||||
getCellSearchText: (row: Row, columnIndex: number) => string;
|
||||
debounceMs?: number;
|
||||
onNavigate?: (match: DataGridSearchMatch) => void;
|
||||
/** 按标签页键控的持久化键;不传则该宿主不参与搜索状态保留。 */
|
||||
persistenceKey?: MaybeRefOrGetter<string | undefined>;
|
||||
/** 列签名,用于避免把陈旧搜索恢复到结构不同的结果上。 */
|
||||
persistenceScopeKey?: MaybeRefOrGetter<string>;
|
||||
};
|
||||
|
||||
const SEARCH_TOKEN_SEPARATOR = /[\s,()><=!&|]+/;
|
||||
@@ -72,12 +77,31 @@ export function useDataGridSearch<Row>(options: UseDataGridSearchOptions<Row>) {
|
||||
searchTimer = undefined;
|
||||
}
|
||||
|
||||
// A single-use token rather than a boolean flag: it is immune to Vue's async
|
||||
// watcher flush and self-invalidates if the user types during the restore window.
|
||||
let restoreToken: { searchText: string; matchIndex: number } | null = null;
|
||||
|
||||
watch(searchText, (value) => {
|
||||
clearTimer();
|
||||
const query = value.trim().toLowerCase();
|
||||
const restoring = restoreToken !== null && restoreToken.searchText === value;
|
||||
if (!query) deferredSearchText.value = "";
|
||||
// Restoring is not typing: resolve the query now so the row set (and, in
|
||||
// "filter" search mode, the content height) is settled before the grid's
|
||||
// scroll restore measures it.
|
||||
else if (restoring) deferredSearchText.value = query;
|
||||
else searchTimer = setTimeout(() => (deferredSearchText.value = query), options.debounceMs ?? 150);
|
||||
|
||||
if (restoring) {
|
||||
// Returning to a tab must not reopen the typeahead popup.
|
||||
suggestions.value = [];
|
||||
suggestionIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
// The user typed before the restore resolved: drop the token so the query they
|
||||
// are writing keeps the normal debounce, index reset and auto-navigate.
|
||||
restoreToken = null;
|
||||
|
||||
const lastToken = value.trim().split(SEARCH_TOKEN_SEPARATOR).pop()?.toLowerCase() ?? "";
|
||||
suggestions.value = lastToken
|
||||
? toValue(options.suggestionColumns ?? options.columns)
|
||||
@@ -88,7 +112,20 @@ export function useDataGridSearch<Row>(options: UseDataGridSearchOptions<Row>) {
|
||||
});
|
||||
|
||||
watch(matchKeys, (value) => {
|
||||
currentMatchIndex.value = value.length ? 0 : -1;
|
||||
const restored = restoreToken;
|
||||
restoreToken = null;
|
||||
if (!value.length) {
|
||||
currentMatchIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
if (restored) {
|
||||
// Re-entering the tab must not scroll to the first match: the grid's own
|
||||
// scroll restore owns the viewport (#8524). Clamp because the match list may
|
||||
// have shifted while the tab was away.
|
||||
currentMatchIndex.value = Math.min(Math.max(restored.matchIndex, 0), value.length - 1);
|
||||
return;
|
||||
}
|
||||
currentMatchIndex.value = 0;
|
||||
const firstMatch = matchAt(0);
|
||||
if (firstMatch) nextTick(() => options.onNavigate?.(firstMatch));
|
||||
});
|
||||
@@ -126,6 +163,41 @@ export function useDataGridSearch<Row>(options: UseDataGridSearchOptions<Row>) {
|
||||
suggestions.value = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Must be called from onMounted, never from the setup body: watch(matchKeys)
|
||||
* eagerly evaluates its source, and seeding a non-empty query would push
|
||||
* matchState past its empty-query early return into `options.rows`, which the
|
||||
* host declares later in its own setup (#8524).
|
||||
*/
|
||||
function restorePersistedState(): boolean {
|
||||
const key = toValue(options.persistenceKey);
|
||||
if (!key) return false;
|
||||
const state = loadDataGridSearchState(key, toValue(options.persistenceScopeKey) ?? "");
|
||||
if (!state) return false;
|
||||
overlayVisible.value = state.overlayVisible;
|
||||
// An empty query never re-triggers the searchText watcher, so the token would
|
||||
// never be consumed — only arm it when there is text to restore.
|
||||
if (!state.searchText) return state.overlayVisible;
|
||||
restoreToken = { searchText: state.searchText, matchIndex: state.currentMatchIndex };
|
||||
searchText.value = state.searchText;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.persistenceKey) {
|
||||
watch(
|
||||
[searchText, deferredSearchText, overlayVisible, currentMatchIndex],
|
||||
() =>
|
||||
saveDataGridSearchState(toValue(options.persistenceKey), {
|
||||
scopeKey: toValue(options.persistenceScopeKey) ?? "",
|
||||
searchText: searchText.value,
|
||||
deferredSearchText: deferredSearchText.value,
|
||||
overlayVisible: overlayVisible.value,
|
||||
currentMatchIndex: currentMatchIndex.value,
|
||||
}),
|
||||
{ flush: "post" },
|
||||
);
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
const pair = SEARCH_PAIRS[event.key];
|
||||
const input = event.target as HTMLInputElement;
|
||||
@@ -152,7 +224,7 @@ export function useDataGridSearch<Row>(options: UseDataGridSearchOptions<Row>) {
|
||||
|
||||
if (getCurrentScope()) onScopeDispose(clearTimer);
|
||||
|
||||
return { searchText, deferredSearchText, overlayVisible, currentMatchIndex, suggestions, suggestionIndex, matches, matchKeys, matchCount, matchAt, matchSet, currentMatch, acceptSuggestion, navigateSuggestion, navigateMatch, close, onKeydown };
|
||||
return { searchText, deferredSearchText, overlayVisible, currentMatchIndex, suggestions, suggestionIndex, matches, matchKeys, matchCount, matchAt, matchSet, currentMatch, acceptSuggestion, navigateSuggestion, navigateMatch, close, onKeydown, restorePersistedState };
|
||||
}
|
||||
|
||||
function dataGridSearchMatchFromKey(key: number): DataGridSearchMatch {
|
||||
|
||||
Reference in new issue
Block a user