1
0
mirror of https://github.com/sendevia/website.git synced 2026-03-05 23:32:45 +08:00

add(appbar): add appbar component with search functionality

This commit is contained in:
2025-11-25 02:38:14 +08:00
parent 9d2c31e73e
commit 4410040bad
4 changed files with 336 additions and 95 deletions

View File

@@ -0,0 +1,334 @@
<script setup lang="ts">
// todo: 优化输入状态的处理
import { ref, computed, onMounted, onUnmounted } from "vue";
import { useGlobalData } from "../composables/useGlobalData";
import { useGlobalScroll } from "../composables/useGlobalScroll";
import { useAllPosts, type Post } from "../composables/useAllPosts";
import { handleTabNavigation } from "../utils/tabNavigation";
const { frontmatter } = useGlobalData();
const { isScrolled } = useGlobalScroll({ threshold: 100 });
const isHome = computed(() => frontmatter.value.home === true);
const appbar = document.querySelector(".appbar") as HTMLInputElement;
const isSearching = ref(false);
const postsRef = useAllPosts(true);
const query = ref("");
const isTyping = ref(false);
// 计算过滤后的文章列表
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;
});
});
// 处理搜索输入框焦点事件
const handleFocus = () => {
isSearching.value = true;
};
// 处理搜索输入框失焦事件
const handleBlur = () => {
if (filteredPosts.value.length === 0) {
isSearching.value = false;
}
};
// 处理搜索结果链接点击后事件
const handleResultClick = () => {
setTimeout(() => {
clearSearchState();
}, 200);
};
// 清除搜索状态并取消焦点
const clearSearchState = () => {
isSearching.value = false;
query.value = "";
const searchInput = document.querySelector(".searchInput") as HTMLInputElement;
if (searchInput) {
searchInput.blur();
}
};
// 处理按键事件
const handleKeydown = (event: KeyboardEvent) => {
const container = document.querySelector(".appbar") as HTMLElement;
const items = container?.querySelectorAll(".searchInput, .item");
if (event.key === "Escape" && isSearching.value) {
event.preventDefault();
handleTabNavigation(container, items, event.key === "Escape");
clearSearchState();
}
if (event.key === "Tab" && isSearching.value && query.value.trim() !== "") {
event.preventDefault();
handleTabNavigation(container, items, event.shiftKey);
}
};
// 监听页面点击事件,用于处理外部点击
const handleDocumentClick = (event: Event) => {
const target = event.target as HTMLElement;
// 如果点击的是appbar外部且不是搜索结果
if (appbar && !appbar.contains(target) && !target.closest(".searchResult")) {
// 没有搜索结果时,可点击空白处退出搜索
// 有搜索结果时,只有点击文章链接后才会退出搜索
if (filteredPosts.value.length === 0) {
clearSearchState();
}
}
};
// 处理移动端返回导航(未验证)
const handlePopState = () => {
if (isSearching.value) {
clearSearchState();
}
};
onMounted(() => {
document.addEventListener("click", handleDocumentClick);
document.addEventListener("keydown", handleKeydown);
window.addEventListener("popstate", handlePopState);
});
onUnmounted(() => {
document.removeEventListener("click", handleDocumentClick);
document.removeEventListener("keydown", handleKeydown);
window.removeEventListener("popstate", handlePopState);
});
</script>
<template>
<div class="appbar" :class="{ scroll: isScrolled, homeLayout: isHome, searching: isSearching, typing: isTyping }">
<div class="actionArea">
<div class="leadingButton">
<MaterialButton color="text" icon="menu" size="xs" />
</div>
<input v-model="query" placeholder="搜索文章" class="searchInput" @focus="handleFocus" @blur="handleBlur" />
<div class="authorAvatar">
<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 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" />
</a>
</div>
</div>
</template>
<style lang="scss">
@use "sass:meta";
@use "../styles/mixin";
.appbar {
display: flex;
align-items: center;
flex-direction: column;
flex-wrap: nowrap;
gap: 8px;
justify-content: space-between;
position: fixed;
top: -64px;
height: 64px;
width: 100%;
padding-inline: 4px;
color: var(--md-sys-color-on-surface);
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 {
display: flex;
align-items: center;
gap: 8px;
justify-content: center;
position: relative;
height: 64px;
width: 100%;
.leadingButton {
display: flex;
flex-direction: column;
flex-shrink: 0;
justify-content: center;
position: absolute;
left: 0px;
height: 48px;
width: 48px;
margin-inline: 4px 8px;
opacity: 1;
transition: opacity var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect);
z-index: 0;
MaterialButton:focus-visible {
@include mixin.focus-ring($thickness: 2);
}
}
.searchInput {
@include mixin.typescale-style("title-medium");
flex-grow: 1;
height: 56px;
min-width: 0px;
margin-inline-start: 56px;
padding-block: 0px;
padding-inline: 24px;
color: var(--md-sys-color-on-surface-variant);
text-overflow: ellipsis;
border: none;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-surface-container);
transition: margin var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
z-index: 1;
&:focus-visible {
@include mixin.focus-ring($thickness: 2);
}
}
.authorAvatar {
display: flex;
align-content: center;
flex-direction: column;
flex-shrink: 0;
flex-wrap: wrap;
justify-content: center;
height: 48px;
width: 48px;
overflow: hidden;
z-index: 0;
img {
height: 32px;
width: 32px;
object-fit: cover;
-webkit-mask: var(--via-svg-mask) no-repeat 0 / 100%;
mask: var(--via-svg-mask) no-repeat 0 / 100%;
}
}
}
.searchResult {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 8px;
position: absolute;
top: 64px;
width: 100%;
padding-inline: 24px;
padding-block-start: 12px;
overflow: scroll;
.item {
width: 100%;
text-decoration: none;
border-radius: var(--md-sys-shape-corner-medium);
.description {
margin-block-end: 12px;
}
&:focus-visible {
@include mixin.focus-ring($thickness: 2, $offset: 4);
}
}
}
&.searching {
height: 100%;
.actionArea {
.leadingButton {
opacity: 0;
transition: opacity var(--md-sys-motion-spring-fast-effect-duration) var(--md-sys-motion-spring-fast-effect);
}
.searchInput {
margin-inline-start: 0px;
transition: margin var(--md-sys-motion-spring-fast-spatial-duration) var(--md-sys-motion-spring-fast-spatial);
}
}
}
&.scroll {
background-color: var(--md-sys-color-surface-container);
.searchInput {
background-color: var(--md-sys-color-surface-container-highest);
}
}
}
@media screen and (max-width: 1600px) {
}
@media screen and (max-width: 1200px) {
}
@media screen and (max-width: 840px) {
.appbar {
top: 0;
opacity: 1;
visibility: visible;
.searchResult {
height: calc(100% - (80px + 64px));
}
}
}
@media screen and (max-width: 600px) {
}
</style>

View File

@@ -1,7 +1,7 @@
import type { Theme } from "vitepress";
import Layout from "./layouts/Default.vue";
import AppBar from "./components/Appbar.vue";
import Button from "./components/Button.vue";
import Footer from "./components/Footer.vue";
import Header from "./components/Header.vue";
@@ -16,6 +16,7 @@ import "./styles/main.scss";
export default {
Layout,
enhanceApp({ app }) {
app.component("AppBar", AppBar);
app.component("Footer", Footer);
app.component("Header", Header);
app.component("ImageViewer", ImageViewer);

View File

@@ -1,93 +0,0 @@
@use "sass:meta";
@use "../mixin";
// https://m3.material.io/components/top-app-bar/
.appbar {
display: flex;
align-items: center;
flex-direction: row;
flex-wrap: nowrap;
gap: 6px;
justify-content: space-between;
position: fixed;
top: -64px;
height: 64px;
width: 100%;
padding: 8px 4px;
background-color: var(--md-sys-color-surface-container-low);
opacity: 0;
overflow: hidden;
transition: var(--md-sys-motion-duration-long1) var(--md-sys-motion-easing-standard);
visibility: hidden;
z-index: 4;
#appbar-dynamic-title {
@include mixin.typescale-style("title-large");
display: flex;
align-items: flex-start;
flex-direction: column;
flex-grow: 1;
justify-content: center;
height: 100%;
margin: auto;
text-align: start;
text-transform: uppercase;
span {
position: absolute;
width: calc(100% - 100px);
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
transition: var(--md-sys-motion-duration-medium4) var(--md-sys-motion-easing-emphasized);
&:nth-child(1) {
transform: translateY(0px);
opacity: 1;
}
&:nth-child(2) {
transform: translateY(45px);
opacity: 0;
}
}
}
&[scroll="true"] {
background-color: var(--md-sys-color-surface-container-high);
#appbar-dynamic-title {
span {
&:nth-child(1) {
transform: translateY(-45px);
opacity: 0;
}
&:nth-child(2) {
transform: translateY(0px);
opacity: 1;
}
}
}
}
@media screen and (max-width: 840px) {
top: 0;
visibility: visible;
}
}

View File

@@ -8,7 +8,6 @@
@include meta.load-css("tokens");
@include meta.load-css("animation");
@include meta.load-css("_components/appbar");
@include meta.load-css("_components/dialog");
@include meta.load-css("_components/loading-splash");
@include meta.load-css("_components/snackbar");