fix(grid): add result search navigation controls
This commit is contained in:
parent
f468eb8f47
commit
8d7acf074d
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue