mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 10:28:44 +00:00
fix(home): polish intro page details and settings highlight
This commit is contained in:
@@ -334,6 +334,7 @@ import {
|
||||
type SettingsSearchEntry,
|
||||
type ToolbarVisibilityItem,
|
||||
} from "@/lib/settings/settingsSearch";
|
||||
import { findSettingsSearchHighlightTarget } from "@/lib/settings/settingsSearchHighlight";
|
||||
import { LOCALE_OPTIONS } from "@/lib/app/localeOptions";
|
||||
import { DEFAULT_WEB_DAV_AUTO_UPLOAD_INTERVAL_MINUTES, DEFAULT_WEB_DAV_REMOTE_PATH, normalizedWebDavAutoUploadInterval, readSyncMethod, writeWebDavAutoUploadFields, writeWebDavBackupSelection, writeSyncMethod, type SyncMethod } from "@/lib/webdav/webdavAutoUploadConfig";
|
||||
import { apiUrl, webPath } from "@/lib/common/webPath";
|
||||
@@ -3407,23 +3408,6 @@ function applySettingsSearchRoute(result: SettingsSearchEntry) {
|
||||
if (result.route?.syncMethodTab) syncMethodTab.value = result.route.syncMethodTab;
|
||||
}
|
||||
|
||||
function normalizeSettingsSearchText(value: string | null | undefined): string {
|
||||
return value?.replace(/\s+/g, " ").trim() ?? "";
|
||||
}
|
||||
|
||||
function findSettingsSearchHighlightTarget(searchRoot: HTMLElement, title: string): HTMLElement {
|
||||
const titleElement = Array.from(searchRoot.querySelectorAll<HTMLElement>("label, h3, h4")).find((element) => normalizeSettingsSearchText(element.textContent) === title);
|
||||
if (!titleElement) return searchRoot;
|
||||
|
||||
let candidate = titleElement.parentElement;
|
||||
while (candidate && candidate !== searchRoot) {
|
||||
if (candidate.classList.contains("rounded-md") && candidate.classList.contains("border")) return candidate;
|
||||
if (candidate.querySelector("input, button, [role='combobox'], textarea")) return candidate;
|
||||
candidate = candidate.parentElement;
|
||||
}
|
||||
return titleElement;
|
||||
}
|
||||
|
||||
async function revealSettingsSearchTarget(result: SettingsSearchEntry) {
|
||||
await nextTick();
|
||||
const searchRoot = settingsContentScrollRef.value?.querySelector<HTMLElement>(`[data-settings-search-id="${result.targetId}"]`);
|
||||
@@ -7539,45 +7523,6 @@ onUnmounted(() => {
|
||||
|
||||
<Separator />
|
||||
|
||||
<div data-settings-search-id="welcome-page-settings" :class="['settings-appearance-group', settingsSearchTargetClass('welcome-page-settings')]">
|
||||
<div class="flex items-center gap-1">
|
||||
<Label>{{ t("settings.welcomePage") }}</Label>
|
||||
<HelpTooltip :label="t('settings.welcomePage')" trigger-class="[&_svg]:h-3 [&_svg]:w-3" content-class="max-w-72">
|
||||
<p>{{ t("settings.welcomePageDescription") }}</p>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
<div class="settings-appearance-choice-grid">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="settings-choice-card h-auto min-w-0 justify-start overflow-hidden whitespace-normal border p-3"
|
||||
:class="editWelcomePageMode === 'intro' ? 'dbx-choice-selected' : ''"
|
||||
:aria-pressed="editWelcomePageMode === 'intro'"
|
||||
@click="editWelcomePageMode = 'intro'"
|
||||
>
|
||||
<div class="w-full min-w-0 text-left">
|
||||
<div class="text-sm font-medium">{{ t("settings.welcomePageIntro") }}</div>
|
||||
<div class="break-words whitespace-normal text-xs text-muted-foreground">{{ t("settings.welcomePageIntroDescription") }}</div>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="settings-choice-card h-auto min-w-0 justify-start overflow-hidden whitespace-normal border p-3"
|
||||
:class="editWelcomePageMode === 'workspace' ? 'dbx-choice-selected' : ''"
|
||||
:aria-pressed="editWelcomePageMode === 'workspace'"
|
||||
@click="editWelcomePageMode = 'workspace'"
|
||||
>
|
||||
<div class="w-full min-w-0 text-left">
|
||||
<div class="text-sm font-medium">{{ t("settings.welcomePageWorkspace") }}</div>
|
||||
<div class="break-words whitespace-normal text-xs text-muted-foreground">{{ t("settings.welcomePageWorkspaceDescription") }}</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="settings-appearance-group">
|
||||
<Label>{{ t("settings.appLayout") }}</Label>
|
||||
<div class="settings-appearance-choice-grid">
|
||||
@@ -7737,6 +7682,45 @@ onUnmounted(() => {
|
||||
<Switch id="quit-on-close" v-model="editQuitOnClose" />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div data-settings-search-id="welcome-page-settings" :class="['settings-appearance-group', settingsSearchTargetClass('welcome-page-settings')]">
|
||||
<div class="flex items-center gap-1">
|
||||
<Label>{{ t("settings.welcomePage") }}</Label>
|
||||
<HelpTooltip :label="t('settings.welcomePage')" trigger-class="[&_svg]:h-3 [&_svg]:w-3" content-class="max-w-72">
|
||||
<p>{{ t("settings.welcomePageDescription") }}</p>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
<div class="settings-appearance-choice-grid">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="settings-choice-card h-auto min-w-0 justify-start overflow-hidden whitespace-normal border p-3"
|
||||
:class="editWelcomePageMode === 'intro' ? 'dbx-choice-selected' : ''"
|
||||
:aria-pressed="editWelcomePageMode === 'intro'"
|
||||
@click="editWelcomePageMode = 'intro'"
|
||||
>
|
||||
<div class="w-full min-w-0 text-left">
|
||||
<div class="text-sm font-medium">{{ t("settings.welcomePageIntro") }}</div>
|
||||
<div class="break-words whitespace-normal text-xs text-muted-foreground">{{ t("settings.welcomePageIntroDescription") }}</div>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="settings-choice-card h-auto min-w-0 justify-start overflow-hidden whitespace-normal border p-3"
|
||||
:class="editWelcomePageMode === 'workspace' ? 'dbx-choice-selected' : ''"
|
||||
:aria-pressed="editWelcomePageMode === 'workspace'"
|
||||
@click="editWelcomePageMode = 'workspace'"
|
||||
>
|
||||
<div class="w-full min-w-0 text-left">
|
||||
<div class="text-sm font-medium">{{ t("settings.welcomePageWorkspace") }}</div>
|
||||
<div class="break-words whitespace-normal text-xs text-muted-foreground">{{ t("settings.welcomePageWorkspaceDescription") }}</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-appearance-group" data-icon-theme-settings>
|
||||
<Label>{{ t("settings.iconTheme") }}</Label>
|
||||
<div class="settings-appearance-choice-grid settings-icon-theme-grid">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ArrowRight, Code2, Database, DatabaseZap, Download, FilePlus2, History, Plus, Search, Settings2, ShieldCheck, Sparkles, Table2 } from "@lucide/vue";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import AppLogo from "@/components/icons/AppLogo.vue";
|
||||
import LightDropdown from "@/components/ui/LightDropdown.vue";
|
||||
import TruncatedTextTooltip from "@/components/ui/TruncatedTextTooltip.vue";
|
||||
import { connectionDriverLabel, connectionIconType, connectionRedactedNameLabel, connectionRedactedOptionSubtitle } from "@/lib/connection/connectionPresentation";
|
||||
import type { WelcomePageMode } from "@/stores/settingsStore";
|
||||
@@ -32,15 +34,28 @@ const emit = defineEmits<{
|
||||
"new-connection": [];
|
||||
"new-query": [];
|
||||
"show-history": [];
|
||||
"import-config": [];
|
||||
"import-config": [source: ImportSource];
|
||||
"open-github": [];
|
||||
"open-mcp-guide": [];
|
||||
"open-website": [];
|
||||
"open-settings": [];
|
||||
}>();
|
||||
|
||||
type ImportSource = "dbx" | "navicat" | "dbeaver" | "datagrip";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const importSourceItems = computed(() => [
|
||||
{ value: "dbx", label: t("sidebar.importDbx") },
|
||||
{ value: "navicat", label: t("sidebar.importNavicat") },
|
||||
{ value: "dbeaver", label: t("sidebar.importDbeaver") },
|
||||
{ value: "datagrip", label: t("sidebar.importDatagrip") },
|
||||
]);
|
||||
|
||||
function selectImportSource(source: string) {
|
||||
emit("import-config", source as ImportSource);
|
||||
}
|
||||
|
||||
function welcomeConnectionSubtitle(connection: ConnectionConfig): string {
|
||||
return connectionRedactedOptionSubtitle(connection) || connectionDriverLabel(connection);
|
||||
}
|
||||
@@ -48,61 +63,80 @@ function welcomeConnectionSubtitle(connection: ConnectionConfig): string {
|
||||
|
||||
<template>
|
||||
<div data-welcome-screen class="min-w-0 flex-1 overflow-x-hidden overflow-y-auto bg-background">
|
||||
<div v-if="welcomePageMode !== 'workspace'" class="welcome-intro mx-auto flex min-h-full w-full min-w-0 max-w-5xl flex-col justify-center px-6 py-10 sm:px-8">
|
||||
<div class="welcome-intro-hero relative overflow-hidden rounded-2xl border px-6 py-10 sm:px-10 sm:py-14">
|
||||
<div class="welcome-intro-glow pointer-events-none absolute -right-24 -top-24 h-64 w-64 rounded-full bg-primary/15 blur-3xl" />
|
||||
<div class="relative max-w-2xl">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-11 w-11 items-center justify-center rounded-xl border bg-background/80 p-2 shadow-sm"><AppLogo class="h-full w-full object-contain" /></div>
|
||||
<div class="text-xs font-medium uppercase tracking-[0.24em] text-muted-foreground">{{ t("welcome.introEyebrow") }}</div>
|
||||
</div>
|
||||
<h1 class="mt-7 text-3xl font-semibold tracking-tight sm:text-4xl">{{ t("welcome.introTitle") }}</h1>
|
||||
<p class="mt-3 max-w-xl text-sm leading-6 text-muted-foreground sm:text-base">{{ t("welcome.introDescription") }}</p>
|
||||
<div class="mt-7 flex flex-wrap items-center gap-2">
|
||||
<button type="button" class="inline-flex h-9 items-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90" @click="emit('new-connection')">
|
||||
<Plus class="h-4 w-4" /> {{ t("welcome.introNewConnection") }} <ArrowRight class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button type="button" class="inline-flex h-9 items-center gap-2 rounded-md border bg-background/70 px-4 text-sm font-medium transition-colors hover:bg-muted" @click="emit('import-config')"><Download class="h-4 w-4" /> {{ t("welcome.introImport") }}</button>
|
||||
<div v-if="welcomePageMode !== 'workspace'" class="welcome-intro-shell flex min-h-full w-full min-w-0 flex-col @container">
|
||||
<div class="welcome-intro mx-auto flex w-full min-w-0 max-w-5xl flex-1 flex-col justify-center px-4 py-8 @3xs:px-6 @2xl:px-8 @2xl:py-10">
|
||||
<div class="welcome-intro-hero relative overflow-hidden rounded-2xl border px-4 py-7 @3xs:px-5 @2xl:px-10 @2xl:py-12 @4xl:py-14">
|
||||
<div class="welcome-intro-glow pointer-events-none absolute -right-24 -top-24 h-64 w-64 rounded-full bg-primary/15 blur-3xl" />
|
||||
<div class="relative max-w-2xl">
|
||||
<div class="flex flex-col items-start gap-2 @3xs:flex-row @3xs:items-center @3xs:gap-3">
|
||||
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border bg-background/80 p-0.5 shadow-sm"><AppLogo class="h-full w-full object-contain" /></div>
|
||||
<div class="text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground @2xl:text-xs @2xl:tracking-[0.24em]">{{ t("welcome.introEyebrow") }}</div>
|
||||
</div>
|
||||
<h1 class="mt-5 text-xl font-semibold tracking-tight @3xs:mt-6 @2xl:text-3xl @4xl:text-4xl">{{ t("welcome.introTitle") }}</h1>
|
||||
<p class="mt-3 max-w-xl text-xs leading-5 text-muted-foreground @2xl:text-sm @2xl:leading-6 @4xl:text-base">{{ t("welcome.introDescription") }}</p>
|
||||
<div class="mt-5 flex flex-wrap items-center gap-2 @2xl:mt-7">
|
||||
<button type="button" class="inline-flex h-9 items-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90" @click="emit('new-connection')">
|
||||
<Plus class="h-4 w-4" /> {{ t("welcome.introNewConnection") }} <ArrowRight class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<LightDropdown
|
||||
model-value=""
|
||||
:items="importSourceItems"
|
||||
:aria-label="t('welcome.introImport')"
|
||||
:trigger-title="t('welcome.introImport')"
|
||||
:trigger-icon="Download"
|
||||
:trigger-label="t('welcome.introImport')"
|
||||
trigger-class="inline-flex h-9 items-center gap-2 rounded-md border bg-background/70 px-4 text-sm font-medium transition-colors hover:bg-muted"
|
||||
trigger-icon-class="h-4 w-4"
|
||||
item-icon-class="h-4 w-4"
|
||||
content-class="w-56"
|
||||
:show-trigger-label="true"
|
||||
:show-chevron="true"
|
||||
:highlight-selected="false"
|
||||
check-position="none"
|
||||
align="start"
|
||||
@update:model-value="selectImportSource"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="rounded-xl border bg-muted/20 p-4">
|
||||
<DatabaseZap class="h-5 w-5 text-primary" />
|
||||
<div class="mt-3 text-sm font-medium">{{ t("welcome.introFeatureConnections") }}</div>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("welcome.introFeatureConnectionsDescription") }}</p>
|
||||
<div class="mt-4 grid gap-3 @2xl:mt-5 @2xl:grid-cols-2 @4xl:grid-cols-4">
|
||||
<div class="rounded-xl border bg-muted/20 p-4">
|
||||
<DatabaseZap class="h-5 w-5 text-primary" />
|
||||
<div class="mt-3 text-sm font-medium">{{ t("welcome.introFeatureConnections") }}</div>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("welcome.introFeatureConnectionsDescription") }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border bg-muted/20 p-4">
|
||||
<Code2 class="h-5 w-5 text-primary" />
|
||||
<div class="mt-3 text-sm font-medium">{{ t("welcome.introFeatureSql") }}</div>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("welcome.introFeatureSqlDescription") }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border bg-muted/20 p-4">
|
||||
<Table2 class="h-5 w-5 text-primary" />
|
||||
<div class="mt-3 text-sm font-medium">{{ t("welcome.introFeatureData") }}</div>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("welcome.introFeatureDataDescription") }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border bg-muted/20 p-4">
|
||||
<Sparkles class="h-5 w-5 text-primary" />
|
||||
<div class="mt-3 text-sm font-medium">{{ t("welcome.introFeatureAi") }}</div>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("welcome.introFeatureAiDescription") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-xl border bg-muted/20 p-4">
|
||||
<Code2 class="h-5 w-5 text-primary" />
|
||||
<div class="mt-3 text-sm font-medium">{{ t("welcome.introFeatureSql") }}</div>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("welcome.introFeatureSqlDescription") }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border bg-muted/20 p-4">
|
||||
<Table2 class="h-5 w-5 text-primary" />
|
||||
<div class="mt-3 text-sm font-medium">{{ t("welcome.introFeatureData") }}</div>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("welcome.introFeatureDataDescription") }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border bg-muted/20 p-4">
|
||||
<Sparkles class="h-5 w-5 text-primary" />
|
||||
<div class="mt-3 text-sm font-medium">{{ t("welcome.introFeatureAi") }}</div>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("welcome.introFeatureAiDescription") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-muted/10 px-4 py-3 text-xs text-muted-foreground">
|
||||
<div class="flex items-center gap-2"><ShieldCheck class="h-4 w-4 text-primary" /> {{ t("welcome.introPrivacyHint") }}</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button type="button" class="inline-flex items-center gap-1.5 hover:text-foreground" @click="emit('show-history')"><History class="h-3.5 w-3.5" /> {{ t("history.title") }}</button>
|
||||
<button type="button" class="inline-flex items-center gap-1.5 hover:text-foreground" @click="emit('open-settings')"><Settings2 class="h-3.5 w-3.5" /> {{ t("welcome.introSettings") }}</button>
|
||||
<div class="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-muted/10 px-3 py-3 text-[11px] text-muted-foreground @2xl:mt-5 @2xl:px-4 @2xl:text-xs">
|
||||
<div class="flex items-center gap-2"><ShieldCheck class="h-4 w-4 text-primary" /> {{ t("welcome.introPrivacyHint") }}</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button type="button" class="inline-flex items-center gap-1.5 hover:text-foreground" @click="emit('show-history')"><History class="h-3.5 w-3.5" /> {{ t("history.title") }}</button>
|
||||
<button type="button" class="inline-flex items-center gap-1.5 hover:text-foreground" @click="emit('open-settings')"><Settings2 class="h-3.5 w-3.5" /> {{ t("welcome.introSettings") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-center gap-3 text-[11px] text-muted-foreground/60">
|
||||
<span>DBX {{ appVersion ? "v" + appVersion : "" }}</span
|
||||
><span>·</span>
|
||||
<a href="#" class="hover:text-foreground" @click.prevent="emit('open-github')">GitHub</a>
|
||||
<span>·</span><button type="button" class="hover:text-foreground" @click="emit('open-website')">{{ t("welcome.mcpLearnMore") }}</button>
|
||||
<div class="mt-5 flex flex-wrap items-center justify-center gap-2 text-[10px] text-muted-foreground/60 @2xl:mt-6 @2xl:gap-3 @2xl:text-[11px]">
|
||||
<span>DBX {{ appVersion ? "v" + appVersion : "" }}</span
|
||||
><span>·</span>
|
||||
<a href="#" class="hover:text-foreground" @click.prevent="emit('open-github')">GitHub</a>
|
||||
<span>·</span><button type="button" class="hover:text-foreground" @click="emit('open-website')">{{ t("welcome.mcpLearnMore") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -163,7 +197,7 @@ function welcomeConnectionSubtitle(connection: ConnectionConfig): string {
|
||||
<button class="flex min-w-0 items-center gap-2 overflow-hidden rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" @click="emit('show-history')">
|
||||
<History class="h-4 w-4 shrink-0" /> <span class="min-w-0 truncate">{{ t("history.title") }}</span>
|
||||
</button>
|
||||
<button class="flex min-w-0 items-center gap-2 overflow-hidden rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" @click="emit('import-config')">
|
||||
<button class="flex min-w-0 items-center gap-2 overflow-hidden rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" @click="emit('import-config', 'dbx')">
|
||||
<Download class="h-4 w-4 shrink-0" /> <span class="min-w-0 truncate">{{ t("sidebar.import") }}</span>
|
||||
</button>
|
||||
<div class="mt-2 min-w-0 overflow-hidden rounded-md bg-muted/30 px-3 py-2 text-xs leading-5 text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment happy-dom
|
||||
// Guards the settings-search "reveal" contract: the highlight must cover the
|
||||
// whole setting block, not just the title row. Searching 首页 used to land on
|
||||
// the label next to the help icon, which read like a rendering glitch.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findSettingsSearchHighlightTarget } from "@/lib/settings/settingsSearchHighlight";
|
||||
|
||||
function searchRoot(html: string): HTMLElement {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = html;
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("findSettingsSearchHighlightTarget", () => {
|
||||
it("highlights the setting block instead of the title row of a help-tooltip setting", () => {
|
||||
const root = searchRoot(`
|
||||
<div class="flex items-center gap-1">
|
||||
<label>首页</label>
|
||||
<button type="button" class="cursor-help" aria-label="首页"></button>
|
||||
</div>
|
||||
<div class="settings-appearance-choice-grid">
|
||||
<button type="button">介绍页</button>
|
||||
<button type="button">工作区</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
expect(findSettingsSearchHighlightTarget(root, "首页")).toBe(root);
|
||||
});
|
||||
|
||||
it("keeps highlighting a bordered setting row that owns the title", () => {
|
||||
const root = searchRoot(`
|
||||
<div class="settings-item rounded-md border">
|
||||
<div class="space-y-1">
|
||||
<label for="quit-on-close">退出时关闭</label>
|
||||
<p>关闭窗口时退出应用</p>
|
||||
</div>
|
||||
<button id="quit-on-close" role="switch"></button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
expect(findSettingsSearchHighlightTarget(root, "退出时关闭")).toBe(root.firstElementChild);
|
||||
});
|
||||
|
||||
it("does not stop at a row that only wraps a label and its help icon", () => {
|
||||
const root = searchRoot(`
|
||||
<div class="rounded-md border">
|
||||
<div class="flex items-center gap-1">
|
||||
<label>首页</label>
|
||||
<button type="button" class="cursor-help" aria-label="首页"></button>
|
||||
</div>
|
||||
<button type="button" role="switch"></button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
expect(findSettingsSearchHighlightTarget(root, "首页")).toBe(root.firstElementChild);
|
||||
});
|
||||
|
||||
it("falls back to the search root when the title cannot be found", () => {
|
||||
const root = searchRoot(`<div><label>其它</label></div>`);
|
||||
|
||||
expect(findSettingsSearchHighlightTarget(root, "首页")).toBe(root);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
export function normalizeSettingsSearchText(value: string | null | undefined): string {
|
||||
return value?.replace(/\s+/g, " ").trim() ?? "";
|
||||
}
|
||||
|
||||
// Help icons render inside the title row of a setting, so their trigger button
|
||||
// must not be mistaken for the setting's own control.
|
||||
function isSettingsSearchHelpAffordance(element: Element): boolean {
|
||||
return element.matches("button.cursor-help");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the element that should be highlighted for a settings search result.
|
||||
*
|
||||
* The search root usually wraps a title row (label + optional help icon) and the
|
||||
* control that belongs to the setting. Walking up from the title must stop at
|
||||
* the block that owns a control; stopping at the title row only highlights the
|
||||
* label, which reads like a rendering glitch.
|
||||
*/
|
||||
export function findSettingsSearchHighlightTarget(searchRoot: HTMLElement, title: string): HTMLElement {
|
||||
const normalizedTitle = normalizeSettingsSearchText(title);
|
||||
const titleElement = Array.from(searchRoot.querySelectorAll<HTMLElement>("label, h3, h4")).find((element) => normalizeSettingsSearchText(element.textContent) === normalizedTitle);
|
||||
if (!titleElement) return searchRoot;
|
||||
|
||||
let candidate = titleElement.parentElement;
|
||||
while (candidate && candidate !== searchRoot) {
|
||||
if (candidate.classList.contains("rounded-md") && candidate.classList.contains("border")) return candidate;
|
||||
const settingControls = Array.from(candidate.querySelectorAll<HTMLElement>("input, button, [role='combobox'], textarea")).filter((control) => !isSettingsSearchHelpAffordance(control));
|
||||
if (settingControls.length > 0) return candidate;
|
||||
candidate = candidate.parentElement;
|
||||
}
|
||||
return searchRoot;
|
||||
}
|
||||
Reference in New Issue
Block a user