mirror of
https://github.com/sendevia/website.git
synced 2026-03-06 07:40:50 +08:00
feat(appbar): refactor search functionality with improved state management
This commit is contained in:
@@ -1,100 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
// todo: 优化输入状态的处理
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
|
||||
import { useGlobalData } from "../composables/useGlobalData";
|
||||
import { useGlobalScroll } from "../composables/useGlobalScroll";
|
||||
import { useAllPosts, type Post } from "../composables/useAllPosts";
|
||||
import { useSearchState } from "../composables/useSearchState";
|
||||
import { useScreenWidth } from "../composables/useScreenWidth";
|
||||
import { handleTabNavigation } from "../utils/tabNavigation";
|
||||
|
||||
const { frontmatter } = useGlobalData();
|
||||
const { isScrolled } = useGlobalScroll({ threshold: 100 });
|
||||
const { isSearchActive, isSearchTyping, deactivateSearch, setSearchFocus, setSearchTyping } = useSearchState();
|
||||
const { isAboveBreakpoint } = useScreenWidth(840);
|
||||
|
||||
const isHome = computed(() => frontmatter.value.home === true);
|
||||
|
||||
const isSearching = ref(false);
|
||||
const postsRef = useAllPosts(true);
|
||||
const query = ref("");
|
||||
const isTyping = ref(false);
|
||||
const appbar = ref<HTMLElement | null>(null);
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const isTabFocusable = computed(() => !isAboveBreakpoint.value);
|
||||
|
||||
// 计算过滤后的文章列表
|
||||
// 计算过滤后的文章
|
||||
const filteredPosts = computed<Post[]>(() => {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
return (postsRef.value ?? []).filter((post) => {
|
||||
const inTitle = post.title?.toLowerCase().includes(q) ?? false;
|
||||
const inDesc = post.description?.toLowerCase().includes(q) ?? false;
|
||||
const inContent = post.content?.toLowerCase().includes(q) ?? false;
|
||||
const inDate = post.date?.toLowerCase().includes(q) ?? false;
|
||||
return inTitle || inDesc || inContent || inDate;
|
||||
if (!q || !postsRef.value) return [];
|
||||
|
||||
return postsRef.value.filter((post) => {
|
||||
const { title = "", description = "", content = "", date = "" } = post;
|
||||
return (
|
||||
title.toLowerCase().includes(q) ||
|
||||
description.toLowerCase().includes(q) ||
|
||||
content.toLowerCase().includes(q) ||
|
||||
date.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// 处理搜索输入框焦点事件
|
||||
// 处理输入框焦点 (仅激活,不处理关闭)
|
||||
const handleFocus = () => {
|
||||
isSearching.value = true;
|
||||
if (!isSearchActive.value) {
|
||||
isSearchActive.value = true;
|
||||
}
|
||||
setSearchFocus(true);
|
||||
};
|
||||
|
||||
// 处理搜索输入框失焦事件
|
||||
// 处理输入框失焦 (只改变焦点状态,不关闭搜索,解决双击问题)
|
||||
const handleBlur = () => {
|
||||
if (filteredPosts.value.length === 0) {
|
||||
isSearching.value = false;
|
||||
setSearchFocus(false);
|
||||
};
|
||||
|
||||
// 处理输入
|
||||
const handleInput = () => {
|
||||
const hasContent = query.value.trim().length > 0;
|
||||
setSearchTyping(hasContent);
|
||||
if (hasContent && !isSearchActive.value) {
|
||||
isSearchActive.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理搜索结果链接点击后事件
|
||||
// 清除搜索状态
|
||||
const clearSearchState = () => {
|
||||
query.value = "";
|
||||
setSearchTyping(false);
|
||||
deactivateSearch();
|
||||
if (searchInput.value) {
|
||||
searchInput.value.blur();
|
||||
}
|
||||
};
|
||||
|
||||
// 点击搜索结果
|
||||
const handleResultClick = () => {
|
||||
setTimeout(() => {
|
||||
clearSearchState();
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// 清除搜索状态并取消焦点
|
||||
const clearSearchState = () => {
|
||||
isSearching.value = false;
|
||||
query.value = "";
|
||||
const searchInput = appbar.value?.querySelector(".searchInput") as HTMLInputElement;
|
||||
if (searchInput) {
|
||||
searchInput.blur();
|
||||
// 处理外部点击
|
||||
const handleDocumentClick = (event: Event) => {
|
||||
if (!isSearchActive.value) return;
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
const isClickInsideInput = searchInput.value && searchInput.value.contains(target);
|
||||
const isClickInsideResults = target.closest(".searchResult");
|
||||
|
||||
if (!isClickInsideInput && !isClickInsideResults && query.value.trim() === "") {
|
||||
clearSearchState();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理按键事件
|
||||
// 监听状态自动聚焦
|
||||
watch(isSearchActive, async (isActive) => {
|
||||
if (isActive && searchInput.value) {
|
||||
await nextTick();
|
||||
setTimeout(() => {
|
||||
searchInput.value?.focus();
|
||||
}, 100);
|
||||
} else if (!isActive) {
|
||||
if (query.value !== "") {
|
||||
query.value = "";
|
||||
setSearchTyping(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 键盘事件
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!isSearchActive.value) return;
|
||||
const container = appbar.value;
|
||||
const items = container?.querySelectorAll(".searchInput, .item") || null;
|
||||
|
||||
if (event.key === "Escape" && isSearching.value) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
handleTabNavigation(container, items, event.key === "Escape");
|
||||
handleTabNavigation(container, items, true);
|
||||
clearSearchState();
|
||||
}
|
||||
|
||||
if (event.key === "Tab" && isSearching.value && query.value.trim() !== "") {
|
||||
if (event.key === "Tab" && query.value.trim() !== "") {
|
||||
event.preventDefault();
|
||||
handleTabNavigation(container, items, event.shiftKey);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听页面点击事件,用于处理外部点击
|
||||
const handleDocumentClick = (event: Event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// 如果点击的是appbar外部且不是搜索结果
|
||||
if (appbar.value && !appbar.value.contains(target) && !target.closest(".searchResult")) {
|
||||
// 没有搜索结果时,可点击空白处退出搜索
|
||||
// 有搜索结果时,只有点击文章链接后才会退出搜索
|
||||
if (filteredPosts.value.length === 0) {
|
||||
clearSearchState();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理移动端返回导航(未验证)
|
||||
const handlePopState = () => {
|
||||
if (isSearching.value) {
|
||||
clearSearchState();
|
||||
}
|
||||
if (isSearchActive.value) clearSearchState();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
@@ -109,30 +136,55 @@ onUnmounted(() => {
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="appbar"
|
||||
class="appbar"
|
||||
:class="{ scroll: isScrolled, homeLayout: isHome, searching: isSearching, typing: isTyping }"
|
||||
:class="{
|
||||
scroll: isScrolled,
|
||||
homeLayout: isHome,
|
||||
searching: isSearchActive,
|
||||
typing: isSearchTyping,
|
||||
}"
|
||||
:tabindex="isTabFocusable ? 0 : -1"
|
||||
>
|
||||
<div class="actionArea">
|
||||
<div class="leadingButton">
|
||||
<MaterialButton color="text" icon="menu" size="xs" />
|
||||
<MaterialButton color="text" icon="menu" size="xs" :tabindex="isTabFocusable ? 0 : -1" />
|
||||
</div>
|
||||
<input v-model="query" placeholder="搜索文章" class="searchInput" @focus="handleFocus" @blur="handleBlur" />
|
||||
<div class="authorAvatar">
|
||||
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="query"
|
||||
placeholder="搜索文章"
|
||||
class="searchInput"
|
||||
:tabindex="isTabFocusable ? 0 : -1"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@input="handleInput"
|
||||
/>
|
||||
|
||||
<div class="authorAvatar" :tabindex="isTabFocusable ? 0 : -1">
|
||||
<img src="/assets/images/avatar.webp" alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="filteredPosts.length" class="searchResult">
|
||||
<a v-for="post in filteredPosts" :key="post.url" :href="post.url" class="item" @click="handleResultClick">
|
||||
|
||||
<div v-if="filteredPosts.length > 0" class="searchResult">
|
||||
<a
|
||||
v-for="(post, index) in filteredPosts"
|
||||
:key="post.url"
|
||||
:href="post.url"
|
||||
class="item"
|
||||
:tabindex="isTabFocusable ? 0 : -1"
|
||||
@click="handleResultClick"
|
||||
>
|
||||
<div class="title">
|
||||
<h3>{{ post.title }}</h3>
|
||||
<p v-if="post.date" class="date">{{ post.date }}</p>
|
||||
</div>
|
||||
<p v-if="post.description" class="description">{{ post.description }}</p>
|
||||
<hr v-if="filteredPosts.indexOf(post) !== filteredPosts.length - 1" />
|
||||
<!-- 只有不是最后一项时才显示分割线 -->
|
||||
<hr v-if="index !== filteredPosts.length - 1" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,9 +204,10 @@ onUnmounted(() => {
|
||||
|
||||
position: fixed;
|
||||
top: -64px;
|
||||
right: 0px;
|
||||
|
||||
height: 64px;
|
||||
width: 100%;
|
||||
width: calc(100% - 96px);
|
||||
|
||||
padding-inline: 4px;
|
||||
|
||||
@@ -162,10 +215,8 @@ onUnmounted(() => {
|
||||
|
||||
background-color: var(--md-sys-color-surface);
|
||||
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
transition: var(--md-sys-motion-spring-slow-effect-duration) var(--md-sys-motion-spring-slow-effect);
|
||||
visibility: hidden;
|
||||
z-index: 998;
|
||||
|
||||
.actionArea {
|
||||
@@ -210,7 +261,7 @@ onUnmounted(() => {
|
||||
height: 56px;
|
||||
min-width: 0px;
|
||||
|
||||
margin-inline-start: 56px;
|
||||
margin-inline-start: 0px;
|
||||
padding-block: 0px;
|
||||
padding-inline: 24px;
|
||||
|
||||
@@ -289,8 +340,12 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
&.searching {
|
||||
top: 0px;
|
||||
|
||||
height: 100%;
|
||||
|
||||
padding: 12px;
|
||||
|
||||
.actionArea {
|
||||
.leadingButton {
|
||||
opacity: 0;
|
||||
@@ -324,9 +379,17 @@ onUnmounted(() => {
|
||||
.appbar {
|
||||
top: 0;
|
||||
|
||||
width: 100%;
|
||||
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
|
||||
.actionArea {
|
||||
.searchInput {
|
||||
margin-inline-start: 56px;
|
||||
}
|
||||
}
|
||||
|
||||
.searchResult {
|
||||
height: calc(100% - (80px + 64px));
|
||||
}
|
||||
|
||||
@@ -2,27 +2,48 @@
|
||||
import { computed } from "vue";
|
||||
import { useGlobalData } from "../composables/useGlobalData";
|
||||
import { useScreenWidth } from "../composables/useScreenWidth";
|
||||
import { useSearchState } from "../composables/useSearchState";
|
||||
|
||||
const { page, theme } = useGlobalData();
|
||||
const { isAboveBreakpoint } = useScreenWidth(840);
|
||||
const { isSearchActive, activateSearch, deactivateSearch } = useSearchState();
|
||||
|
||||
// 计算导航段落
|
||||
const navSegment = computed(() => {
|
||||
const items = theme.value.navSegment;
|
||||
if (Array.isArray(items) && items.length > 0) return items;
|
||||
return Array.isArray(items) && items.length > 0 ? items : [];
|
||||
});
|
||||
|
||||
function isActive(link: string) {
|
||||
const current = page.value.relativePath.replace(/(\/index)?\.md$/, "");
|
||||
const target = link.replace(/\/$/, "").replace(/\.html$/, "");
|
||||
return current === target.replace(/^\//, "") || (target === "" && current === "index");
|
||||
// 规范化路径
|
||||
function normalizePath(path: string): string {
|
||||
return path.replace(/(\/index)?\.(md|html)$/, "").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
// 检查链接是否为当前活动链接
|
||||
function isActive(link: string): boolean {
|
||||
const currentPath = normalizePath(page.value.relativePath);
|
||||
const targetPath = normalizePath(link);
|
||||
|
||||
return currentPath === targetPath.replace(/^\//, "") || (targetPath === "" && currentPath === "index");
|
||||
}
|
||||
|
||||
// 处理fab点击事件 - 切换搜索状态
|
||||
function toggleSearch(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
|
||||
if (isSearchActive.value) {
|
||||
deactivateSearch();
|
||||
} else {
|
||||
activateSearch();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav :class="isAboveBreakpoint ? 'rail' : 'bar'">
|
||||
<a href="/search" class="fab">
|
||||
<button class="fab" @mousedown.prevent @click.stop="toggleSearch">
|
||||
<span>search</span>
|
||||
</a>
|
||||
</button>
|
||||
<div class="destinations">
|
||||
<div class="segment" v-for="item in navSegment" :key="item.link" :class="isActive(item.link) ? 'active' : 'inactive'">
|
||||
<a :href="item.link">
|
||||
@@ -82,6 +103,7 @@ nav {
|
||||
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
|
||||
border: none;
|
||||
border-radius: var(--md-sys-shape-corner-large);
|
||||
|
||||
background-color: var(--md-sys-color-secondary-container);
|
||||
|
||||
35
.vitepress/theme/composables/useSearchState.ts
Normal file
35
.vitepress/theme/composables/useSearchState.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
const isSearchActive = ref(false);
|
||||
const isSearchFocused = ref(false);
|
||||
const isSearchTyping = ref(false);
|
||||
|
||||
export function useSearchState() {
|
||||
const activateSearch = () => {
|
||||
isSearchActive.value = true;
|
||||
};
|
||||
|
||||
const deactivateSearch = () => {
|
||||
isSearchActive.value = false;
|
||||
isSearchFocused.value = false;
|
||||
isSearchTyping.value = false;
|
||||
};
|
||||
|
||||
const setSearchFocus = (focused: boolean) => {
|
||||
isSearchFocused.value = focused;
|
||||
};
|
||||
|
||||
const setSearchTyping = (typing: boolean) => {
|
||||
isSearchTyping.value = typing;
|
||||
};
|
||||
|
||||
return {
|
||||
isSearchActive,
|
||||
isSearchFocused,
|
||||
isSearchTyping,
|
||||
activateSearch,
|
||||
deactivateSearch,
|
||||
setSearchFocus,
|
||||
setSearchTyping,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user