refactor(query): extract shared result tab popover content

This commit is contained in:
lxk955
2026-10-14 15:44:23 +08:00
committed by GitHub
parent bdba3542bb
commit 56bc672173
4 changed files with 316 additions and 109 deletions
@@ -1,14 +1,14 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Check, ChevronDown, Copy, FileCode2 } from "@lucide/vue";
import { ChevronDown, Copy, FileCode2 } 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";
import ResultSetNavigatorPopoverContent from "@/components/layout/ResultSetNavigatorPopoverContent.vue";
import { filterResultItems, type ResultItem } from "@/components/layout/resultSetNavigator";
type ResultItem = ReturnType<typeof tabularResultItems>[number];
const props = withDefaults(
defineProps<{
items: ResultItem[];
@@ -32,29 +32,16 @@ const emit = defineEmits<{
}>();
const { t } = useI18n();
const scroller = ref<HTMLElement | null>(null);
const list = ref<HTMLElement | null>(null);
const searchInput = ref<HTMLInputElement | null>(null);
const batchSearchInput = ref<HTMLInputElement | null>(null);
const open = ref(false);
const search = ref("");
const batchOpen = ref(false);
const batchSearch = ref("");
const selectedIndexes = ref<Set<number>>(new Set());
const { hasTabOverflow, scrollThumbLeftPercent, scrollThumbWidthPercent, isScrollbarDragging, updateScrollButtons, onTabsWheel, startScrollbarDrag } = useTabScroll(scroller);
const thumbStyle = computed(() => ({ left: `${scrollThumbLeftPercent.value}%`, width: `${scrollThumbWidthPercent.value}%` }));
function filterResultItems(items: ResultItem[], rawQuery: string) {
const query = rawQuery.trim().toLocaleLowerCase();
if (!query) return items;
// A number is an exact result ordinal, not a substring of every SQL statement.
if (/^\d+$/.test(query)) return items.filter((item) => item.n === Number(query));
return items.filter((item) => [item.label, item.title, item.result.sourceName, item.result.sourceLabel, item.result.sourceStatement, t("tabs.resultN", { n: item.n })].some((value) => value?.toLocaleLowerCase().includes(query)));
}
const filteredItems = computed(() => {
return filterResultItems(props.items, search.value);
});
const filteredBatchItems = computed(() => {
return filterResultItems(props.items, batchSearch.value);
return filterResultItems(props.items, batchSearch.value, t);
});
const selectedBatchItems = computed(() => props.items.filter((item) => selectedIndexes.value.has(item.index)));
// The active result may be a server message that tabularResultItems filters out;
@@ -80,9 +67,6 @@ watch(
() => nextTick(revealActive),
{ flush: "post", immediate: true },
);
watch(open, () => {
search.value = "";
});
function select(item: ResultItem) {
emit("select", item);
@@ -131,33 +115,6 @@ watch(
},
{ immediate: true },
);
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>
@@ -179,37 +136,7 @@ function onListKeydown(event: KeyboardEvent, index: number) {
<ChevronDown class="h-3.5 w-3.5 shrink-0 opacity-70" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" 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>
<ResultSetNavigatorPopoverContent align="start" :items="items" :active-index="activeIndex" :active="active" @select="select" />
</Popover>
<Button
v-else
@@ -245,37 +172,7 @@ function onListKeydown(event: KeyboardEvent, index: number) {
<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>
<ResultSetNavigatorPopoverContent align="end" :items="items" :active-index="activeIndex" :active="active" @select="select" />
</Popover>
</template>
<Popover v-if="items.length > 1" v-model:open="batchOpen">
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Check } from "@lucide/vue";
import { PopoverContent } from "@/components/ui/popover";
import { filterResultItems, type ResultItem } from "@/components/layout/resultSetNavigator";
const props = withDefaults(
defineProps<{
items: ResultItem[];
activeIndex: number;
active: boolean;
align?: "start" | "center" | "end";
}>(),
{
align: "start",
},
);
const emit = defineEmits<{
select: [item: ResultItem];
}>();
const { t } = useI18n();
const searchInput = ref<HTMLInputElement | null>(null);
const list = ref<HTMLElement | null>(null);
const search = ref("");
const filteredItems = computed(() => {
return filterResultItems(props.items, search.value, t);
});
function onOpenAutoFocus() {
search.value = "";
searchInput.value?.focus();
}
function select(item: ResultItem) {
emit("select", item);
}
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>
<PopoverContent :align="align" class="w-96 max-h-[var(--reka-popover-content-available-height)] max-w-[calc(100vw-2rem)] gap-1 p-1" @open-auto-focus.prevent="onOpenAutoFocus">
<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>
</template>
@@ -0,0 +1,205 @@
// @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 { Popover, PopoverTrigger } from "@/components/ui/popover";
import ResultSetNavigatorPopoverContent from "../ResultSetNavigatorPopoverContent.vue";
import { filterResultItems, type ResultItem } from "../resultSetNavigator";
import { tabularResultItems } from "@/lib/tabs/tabPresentation";
import type { QueryResult } from "@/types/database";
let app: App | undefined;
afterEach(() => {
app?.unmount();
document.body.replaceChildren();
});
async function mountPopoverContent(
options: {
items?: ResultItem[];
activeIndex?: number;
active?: boolean;
align?: "start" | "center" | "end";
} = {},
) {
const queryResults: QueryResult[] = [
{ columns: ["id"], rows: [[1]], sourceName: "first query", sourceStatement: "SELECT 1" },
{ columns: ["id"], rows: [[2]], sourceLabel: "second label", sourceStatement: "SELECT 2" },
{ columns: ["id"], rows: [[3]], sourceStatement: "SELECT 3" },
];
const items = options.items ?? tabularResultItems(queryResults);
const select = vi.fn();
const container = document.createElement("div");
document.body.append(container);
app = createApp({
components: { Popover, PopoverTrigger, ResultSetNavigatorPopoverContent },
setup() {
return {
items,
activeIndex: options.activeIndex ?? 0,
active: options.active ?? true,
align: options.align ?? "start",
select,
};
},
template: `
<Popover :open="true">
<PopoverTrigger><button>Trigger</button></PopoverTrigger>
<ResultSetNavigatorPopoverContent
:items="items"
:active-index="activeIndex"
:active="active"
:align="align"
@select="select"
/>
</Popover>
`,
});
app.use(
createI18n({
legacy: false,
locale: "en",
messages: {
en: {
tabs: {
resultN: "Result {n}",
resultSets: "Result sets",
searchResults: "Search",
noMatchingResults: "No matches",
},
},
},
}),
);
app.mount(container);
await nextTick();
await nextTick();
const popup = document.querySelector<HTMLElement>('[data-slot="popover-content"]')!;
const searchInput = popup.querySelector<HTMLInputElement>('input[type="search"]')!;
return { container, popup, searchInput, select, items };
}
describe("ResultSetNavigatorPopoverContent", () => {
it("renders result items and shows checkmark on active item", async () => {
const { popup } = await mountPopoverContent({ activeIndex: 1, active: true });
const buttons = popup.querySelectorAll<HTMLButtonElement>('div[aria-label="Result sets"] button');
expect(buttons).toHaveLength(3);
expect(buttons[0]?.textContent).toContain("Result 1");
expect(buttons[1]?.textContent).toContain("Result 2");
expect(buttons[2]?.textContent).toContain("Result 3");
// Item 1 is active (storage index 1), so button 1's svg should not have 'invisible'
const check1 = buttons[1]?.querySelector("svg");
expect(check1?.classList.contains("invisible")).toBe(false);
// Button 0's svg should have 'invisible'
const check0 = buttons[0]?.querySelector("svg");
expect(check0?.classList.contains("invisible")).toBe(true);
});
it("filters items when typing in search input", async () => {
const { popup, searchInput } = await mountPopoverContent();
searchInput.value = "second";
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
const buttons = popup.querySelectorAll<HTMLButtonElement>('div[aria-label="Result sets"] button');
expect(buttons).toHaveLength(1);
expect(buttons[0]?.textContent).toContain("Result 2");
});
it("shows empty state when search query matches nothing", async () => {
const { popup, searchInput } = await mountPopoverContent();
searchInput.value = "nonexistent query";
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
const buttons = popup.querySelectorAll<HTMLButtonElement>('div[aria-label="Result sets"] button');
expect(buttons).toHaveLength(0);
const empty = popup.querySelector('[role="status"]');
expect(empty?.textContent).toBe("No matches");
});
it("emits select event when clicking an item", async () => {
const { popup, select, items } = await mountPopoverContent();
const buttons = popup.querySelectorAll<HTMLButtonElement>('div[aria-label="Result sets"] button');
buttons[2]?.click();
expect(select).toHaveBeenCalledOnce();
expect(select).toHaveBeenCalledWith(items[2]);
});
it("selects first matching item when pressing Enter in search input", async () => {
const { searchInput, select, items } = await mountPopoverContent();
searchInput.value = "3";
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
searchInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(select).toHaveBeenCalledOnce();
expect(select).toHaveBeenCalledWith(items[2]);
});
it("supports keyboard navigation with arrow keys, Home, and End", async () => {
const { popup, searchInput } = await mountPopoverContent();
const buttons = popup.querySelectorAll<HTMLButtonElement>('div[aria-label="Result sets"] button');
// ArrowDown in search input focuses the first item
searchInput.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
expect(document.activeElement).toBe(buttons[0]);
// ArrowDown on item moves to next item
buttons[0]?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
expect(document.activeElement).toBe(buttons[1]);
// End moves to last item
buttons[1]?.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
expect(document.activeElement).toBe(buttons[2]);
// Home moves to first item
buttons[2]?.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
expect(document.activeElement).toBe(buttons[0]);
// ArrowUp on first item returns focus to search input
buttons[0]?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }));
expect(document.activeElement).toBe(searchInput);
// ArrowUp in search input focuses the last item
searchInput.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }));
expect(document.activeElement).toBe(buttons[2]);
});
});
describe("filterResultItems helper", () => {
it("returns all items when query is empty or whitespace", () => {
const queryResults: QueryResult[] = [
{ columns: ["id"], rows: [[1]], sourceStatement: "SELECT 1" },
{ columns: ["id"], rows: [[2]], sourceStatement: "SELECT 2" },
];
const items = tabularResultItems(queryResults);
const t = (key: string, params?: Record<string, unknown>) => (key === "tabs.resultN" ? `Result ${params?.n}` : key);
expect(filterResultItems(items, "", t)).toEqual(items);
expect(filterResultItems(items, " ", t)).toEqual(items);
});
it("filters by exact ordinal when query is a number", () => {
const queryResults: QueryResult[] = Array.from({ length: 15 }, (_, i) => ({
columns: ["id"],
rows: [[i + 1]],
sourceStatement: `SELECT ${i + 1}`,
}));
const items = tabularResultItems(queryResults);
const t = (key: string, params?: Record<string, unknown>) => (key === "tabs.resultN" ? `Result ${params?.n}` : key);
const matched = filterResultItems(items, "12", t);
expect(matched).toHaveLength(1);
expect(matched[0]?.n).toBe(12);
});
});
@@ -0,0 +1,11 @@
import type { tabularResultItems } from "@/lib/tabs/tabPresentation";
export type ResultItem = ReturnType<typeof tabularResultItems>[number];
export function filterResultItems(items: ResultItem[], rawQuery: string, t: (key: string, params?: Record<string, unknown>) => string): ResultItem[] {
const query = rawQuery.trim().toLocaleLowerCase();
if (!query) return items;
// A number is an exact result ordinal, not a substring of every SQL statement.
if (/^\d+$/.test(query)) return items.filter((item) => item.n === Number(query));
return items.filter((item) => [item.label, item.title, item.result.sourceName, item.result.sourceLabel, item.result.sourceStatement, t("tabs.resultN", { n: item.n })].some((value) => value?.toLocaleLowerCase().includes(query)));
}