fix(grid): add result search navigation controls

This commit is contained in:
t8y2 2026-07-21 12:09:04 +08:00
parent f468eb8f47
commit 8d7acf074d
5 changed files with 146 additions and 10 deletions

View File

@ -7605,6 +7605,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
:current-match-index="currentMatchIndex"
:has-deferred-search-text="!!deferredClientSearchText"
@keydown="onSearchKeydown"
@navigate="navigateMatch"
@close="closeSearch"
@accept-suggestion="
suggestionIndex = $event;

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from "vue";
import { Search, X } from "@lucide/vue";
import { ChevronDown, ChevronUp, Search, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
@ -18,6 +18,7 @@ const searchText = defineModel<string>("text", { default: "" });
const emit = defineEmits<{
keydown: [event: KeyboardEvent];
close: [];
navigate: [delta: number];
acceptSuggestion: [index: number];
hoverSuggestion: [index: number];
}>();
@ -29,6 +30,11 @@ function onSuggestionMouseDown(event: MouseEvent, index: number) {
emit("acceptSuggestion", index);
}
function keepSearchInputFocused(event: MouseEvent) {
// Pointer navigation should not move focus away from the search input; keyboard activation still uses click.
event.preventDefault();
}
defineExpose({
focus: (select = false) => {
searchInput.value?.focus();
@ -68,7 +74,31 @@ defineExpose({
</div>
<span v-if="props.matchCount > 0" class="text-xs text-muted-foreground shrink-0">{{ props.currentMatchIndex + 1 }}/{{ props.matchCount }}</span>
<span v-else-if="props.hasDeferredSearchText" class="text-xs text-muted-foreground shrink-0">0</span>
<button type="button" class="text-muted-foreground hover:text-foreground shrink-0" @click="emit('close')"><X class="w-3.5 h-3.5" /></button>
<button
type="button"
class="text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:pointer-events-none shrink-0"
:disabled="props.matchCount === 0"
:title="t('search.prevMatch')"
:aria-label="t('search.prevMatch')"
@mousedown="keepSearchInputFocused"
@click="emit('navigate', -1)"
>
<ChevronUp class="w-3.5 h-3.5" />
</button>
<button
type="button"
class="text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:pointer-events-none shrink-0"
:disabled="props.matchCount === 0"
:title="t('search.nextMatch')"
:aria-label="t('search.nextMatch')"
@mousedown="keepSearchInputFocused"
@click="emit('navigate', 1)"
>
<ChevronDown class="w-3.5 h-3.5" />
</button>
<button type="button" class="text-muted-foreground hover:text-foreground shrink-0" :title="t('search.close')" :aria-label="t('search.close')" @click="emit('close')">
<X class="w-3.5 h-3.5" />
</button>
</div>
</Transition>
</template>

View File

@ -17,7 +17,28 @@ vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@lucide/vue", async () => {
const { createPassthroughStub } = await import("./vueHostHarness");
const icon = createPassthroughStub("Icon", "i");
return { Check: icon, ChevronDown: icon, ChevronLeft: icon, ChevronRight: icon, ChevronsLeft: icon, ChevronsRight: icon, Filter: icon, Loader2: icon, Upload: icon, Search: icon, X: icon, Code2: icon, Copy: icon, Eye: icon, EyeOff: icon, Info: icon, Pencil: icon, Plus: icon, Trash2: icon };
return {
Check: icon,
ChevronDown: icon,
ChevronUp: icon,
ChevronLeft: icon,
ChevronRight: icon,
ChevronsLeft: icon,
ChevronsRight: icon,
Filter: icon,
Loader2: icon,
Upload: icon,
Search: icon,
X: icon,
Code2: icon,
Copy: icon,
Eye: icon,
EyeOff: icon,
Info: icon,
Pencil: icon,
Plus: icon,
Trash2: icon,
};
});
vi.mock("@/components/ui/button", async () => ({ Button: (await import("./vueHostHarness")).createPassthroughStub("Button", "button") }));
@ -95,10 +116,11 @@ beforeEach(() => {
});
describe("DataGridSearchBar", () => {
it("focuses/selects the input and forwards keyboard and suggestion interactions", () => {
it("focuses/selects the input and forwards keyboard, navigation, and suggestion interactions", async () => {
const keydown = vi.fn();
const acceptSuggestion = vi.fn();
const hoverSuggestion = vi.fn();
const navigate = vi.fn();
const close = vi.fn();
const mounted = mountComponent(DataGridSearchBar, {
open: true,
@ -111,6 +133,7 @@ describe("DataGridSearchBar", () => {
onKeydown: keydown,
onAcceptSuggestion: acceptSuggestion,
onHoverSuggestion: hoverSuggestion,
onNavigate: navigate,
onClose: close,
});
const input = findOne(mounted.root, (node) => node.type === "input");
@ -128,9 +151,20 @@ describe("DataGridSearchBar", () => {
dispatch(suggestion, "mouseenter");
expect(hoverSuggestion).toHaveBeenCalledWith(0);
const closeButton = findOne(mounted.root, (node) => node.type === "button");
const previousButton = findOne(mounted.root, (node) => node.props["aria-label"] === "search.prevMatch");
const nextButton = findOne(mounted.root, (node) => node.props["aria-label"] === "search.nextMatch");
expect(dispatch(previousButton, "mousedown").defaultPrevented).toBe(true);
dispatch(previousButton, "click");
dispatch(nextButton, "click");
expect(navigate.mock.calls).toEqual([[-1], [1]]);
const closeButton = findOne(mounted.root, (node) => node.props["aria-label"] === "search.close");
dispatch(closeButton, "click");
expect(close).toHaveBeenCalledOnce();
await mounted.setProps({ matchCount: 0 });
expect(previousButton.props.disabled).toBe(true);
expect(nextButton.props.disabled).toBe(true);
});
});

View File

@ -1,7 +1,18 @@
import { nextTick, ref } from "vue";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useDataGridSearch } from "@/composables/useDataGridSearch";
afterEach(() => {
vi.useRealTimers();
});
async function flushSearchDebounce() {
await nextTick();
vi.runOnlyPendingTimers();
await nextTick();
await nextTick();
}
describe("useDataGridSearch", () => {
it("debounces matching across columns and cells", async () => {
vi.useFakeTimers();
@ -15,7 +26,6 @@ describe("useDataGridSearch", () => {
expect(search.matches.value).toEqual([{ kind: "cell", displayRow: 0, col: 1 }]);
// matchSet 用数值 key(displayRow+1)*65536+col
expect(search.matchSet.value.has((0 + 1) * 65536 + 1)).toBe(true);
vi.useRealTimers();
});
it("keys column-name matches with displayRow -1", async () => {
@ -27,7 +37,6 @@ describe("useDataGridSearch", () => {
await nextTick();
expect(search.matches.value).toEqual([{ kind: "column", displayRow: -1, col: 1 }]);
expect(search.matchSet.value.has((-1 + 1) * 65536 + 1)).toBe(true);
vi.useRealTimers();
});
it("suggests columns and replaces only the active token", async () => {
@ -39,4 +48,63 @@ describe("useDataGridSearch", () => {
expect(search.acceptSuggestion()).toBe(true);
expect(search.searchText.value).toBe("status = customer_id");
});
it("navigates forward and backward with first/last wrapping", async () => {
vi.useFakeTimers();
const onNavigate = vi.fn();
const search = useDataGridSearch({
columns: ["left", "right"],
rows: [
["hit", "hit"],
["none", "hit"],
],
getCellSearchText: (row, column) => row[column],
onNavigate,
});
search.searchText.value = "hit";
await flushSearchDebounce();
expect(search.currentMatchIndex.value).toBe(0);
search.navigateMatch(1);
expect(search.currentMatchIndex.value).toBe(1);
expect(onNavigate).toHaveBeenLastCalledWith({ kind: "cell", displayRow: 0, col: 1 });
search.navigateMatch(-1);
expect(search.currentMatchIndex.value).toBe(0);
search.navigateMatch(-1);
expect(search.currentMatchIndex.value).toBe(2);
expect(onNavigate).toHaveBeenLastCalledWith({ kind: "cell", displayRow: 1, col: 1 });
search.navigateMatch(1);
expect(search.currentMatchIndex.value).toBe(0);
});
it("resets navigation when results change or the query is cleared", async () => {
vi.useFakeTimers();
const rows = ref([["hit"], ["hit"]]);
const onNavigate = vi.fn();
const search = useDataGridSearch({ columns: ["value"], rows, getCellSearchText: (row, column) => row[column], onNavigate });
search.searchText.value = "hit";
await flushSearchDebounce();
search.navigateMatch(1);
expect(search.currentMatchIndex.value).toBe(1);
rows.value = [["hit"]];
await nextTick();
await nextTick();
expect(search.currentMatchIndex.value).toBe(0);
expect(search.currentMatch.value).toEqual({ kind: "cell", displayRow: 0, col: 0 });
rows.value = [];
await nextTick();
expect(search.currentMatchIndex.value).toBe(-1);
expect(search.currentMatch.value).toBeNull();
rows.value = [["hit"]];
await nextTick();
search.searchText.value = "";
await nextTick();
expect(search.matches.value).toEqual([]);
expect(search.currentMatchIndex.value).toBe(-1);
});
});

View File

@ -91,8 +91,11 @@ export function useDataGridSearch<Row>(options: UseDataGridSearchOptions<Row>) {
}
function navigateMatch(delta: number) {
if (!matches.value.length) return;
currentMatchIndex.value = (currentMatchIndex.value + delta + matches.value.length) % matches.value.length;
const matchCount = matches.value.length;
if (!matchCount) return;
// Results may change between input and navigation; recover from a stale index in the requested direction.
const currentIndex = currentMatchIndex.value >= 0 && currentMatchIndex.value < matchCount ? currentMatchIndex.value : delta < 0 ? 0 : -1;
currentMatchIndex.value = (((currentIndex + delta) % matchCount) + matchCount) % matchCount;
const match = currentMatch.value;
if (match) options.onNavigate?.(match);
}