Files
ONE/frontend/src/ui/yohaku/YohakuTop.vue
T

186 lines
6.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { site } from '../../site'
import NavBurger from '../../components/NavBurger.vue'
import AuthorAvatar from '../../components/AuthorAvatar.vue'
import {
PhHouse,
PhArchiveBox,
PhTag,
PhSquaresFour,
PhUserCircle
} from '@phosphor-icons/vue'
// 余白顶栏。对应设计稿的 <header class="top">:品牌 / 导航 / 操作三块,
// 滚动过 8px 加 .scrolled(设计稿的「四态里的第一态」)。
// 样式在 ui/yohaku/chrome.css,选择器已收敛到 html[data-ui="vivid"] 下。
//
// 窄屏例外:内联导航退场,换成一枚常驻的顶部玻璃条(品牌 + 汉堡)+
// 右侧抽屉(.drawer,chrome.css)。抽屉是 aria-modal 对话框:开着时锁背景
// 滚动、Esc 收起、焦点交给面板,路由一变自动收。
// 明暗切换在抽屉 footer 和页脚各有一枚(≤720px 才显示),桌面走顶栏 ◐。
const route = useRoute()
const scrolled = ref(false)
const menuOpen = ref(false)
const burger = ref(null)
const drawerEl = ref(null)
// 手机端顶栏:向下滑动藏起来给内容让路,向上一滑就回来。
// 抽屉开着时不藏——汉堡就是 ✕,藏了就没法关菜单。
const barHidden = ref(false)
let lastY = 0
const links = [
{ to: '/', label: '时间线', icon: PhHouse },
{ to: '/archive', label: '归档', icon: PhArchiveBox },
{ to: '/tags', label: '标签', icon: PhTag },
{ to: '/projects', label: '作品', icon: PhSquaresFour },
{ to: '/about', label: '关于', icon: PhUserCircle }
]
const title = () => site.site_title?.split(/[·|]/)[0]?.trim() || 'ONE'
function isOn(l) {
return l.to === '/' ? route.path === '/' : route.path.startsWith(l.to)
}
function onScroll() {
const y = window.scrollY || 0
scrolled.value = y > 8
// 页首附近不藏(没什么可让的路),滚过一屏的 1/8 才启用;
// 方向反转立即回来,误差抖动(|Δ|<2px)不触发
if (menuOpen.value) {
barHidden.value = false
} else if (Math.abs(y - lastY) >= 2) {
if (y > lastY && y > 80) barHidden.value = true
else barHidden.value = false
}
lastY = y
}
function toggleMenu() {
menuOpen.value ? closeMenu() : openMenu()
}
function openMenu() {
menuOpen.value = true
// 焦点交给对话框本身(aria-modal 承诺过),Tab 第一下才落到「时间线」。
// 不直接 focus 第一个链接:即使 touch 上不长出焦点环,也会让首项看起来
// 「被选中」,和 .on 的高亮抢意思。preventScroll 防止 iOS 顺手滚背景。
nextTick(() => drawerEl.value?.focus({ preventScroll: true }))
}
function closeMenu() {
menuOpen.value = false
const b = burger.value?.$el || burger.value
nextTick(() => b?.focus?.({ preventScroll: true }))
}
// 抽屉铺满全屏时锁住底层滚动(iOS 上不锁背景会跟着手指滑)
watch(menuOpen, (v) => { document.body.style.overflow = v ? 'hidden' : '' })
// 点了链接 → 路由变了 → 抽屉收起
watch(() => route.fullPath, () => { menuOpen.value = false })
function onKey(e) {
if (e.key === 'Escape') closeMenu()
}
// 窗口拉宽回桌面必须自己退场:v-if 是 JS 状态,CSS 媒体查询管不着它
const mqWide = window.matchMedia('(min-width: 721px)')
function onWide(e) { if (e.matches) menuOpen.value = false }
onMounted(() => {
window.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('keydown', onKey)
mqWide.addEventListener('change', onWide)
onScroll()
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', onScroll)
window.removeEventListener('keydown', onKey)
mqWide.removeEventListener('change', onWide)
document.body.style.overflow = ''
menuOpen.value = false
})
// 设计稿里主题键是 ◐:亮色时显示(点它去暗),暗色时切回亮色。
function toggleTheme() {
// site.themeMode 是站点级偏好(auto / light / dark),site.js 里有 watcher 负责落盘与写 data-theme。
// 这里直接改它,不自己碰 document : 单一数据源。
const dark = document.documentElement.getAttribute('data-theme') === 'vivid-dark'
site.themeMode = dark ? 'light' : 'dark'
}
</script>
<template>
<header class="top" :class="{ scrolled }">
<div class="top-in">
<RouterLink class="brand" to="/">
<span class="av"><AuthorAvatar /></span>
<span>{{ title() }}</span>
</RouterLink>
<nav class="nav" aria-label="站点导航">
<RouterLink
v-for="l in links"
:key="l.to"
:to="l.to"
:class="{ on: isOn(l) }"
>{{ l.label }}</RouterLink>
</nav>
<div class="top-acts">
<button class="icon-btn theme-btn" type="button" title="切换明暗" aria-label="切换明暗" @click="toggleTheme">◐</button>
</div>
</div>
</header>
<!-- 窄屏:顶部玻璃条(品牌 + 汉堡)。桌面 display:none。
必须待在 .top 外面:.top 有 backdrop-filter,会给 fixed 后代当包含块。
向下滑动自动藏起(.bar-hide),给内容让路;向上一滑立刻回来。 -->
<header class="top-m" :class="{ 'bar-hide': barHidden }">
<RouterLink class="top-m-brand" to="/">
<span class="av"><AuthorAvatar /></span>
<span>{{ title() }}</span>
</RouterLink>
<NavBurger
ref="burger"
:open="menuOpen"
aria-controls="v-drawer"
:aria-label="menuOpen ? '关闭菜单' : '打开菜单'"
@toggle="toggleMenu"
/>
</header>
<!-- 窄屏抽屉:scrim + 右侧滑入的面板。品牌条压在 scrim 之上不消失,
汉堡原位并成 ✕,不需要重复的菜单头。 -->
<Transition name="v-drawer">
<div
v-if="menuOpen"
id="v-drawer"
ref="drawerEl"
class="drawer-root"
role="dialog"
aria-modal="true"
aria-label="站点导航"
tabindex="-1"
>
<div class="scrim" @click="closeMenu" />
<aside class="drawer">
<nav class="drawer-nav" aria-label="站点导航">
<RouterLink
v-for="l in links"
:key="l.to"
:to="l.to"
:class="{ on: isOn(l) }"
>
<component :is="l.icon" class="ic" aria-hidden="true" />
<span>{{ l.label }}</span>
</RouterLink>
</nav>
<div class="drawer-foot">
<button class="chip" type="button" @click="toggleTheme">◐ 切换明暗</button>
<p v-if="site.site_desc" class="m-desc">{{ site.site_desc }}</p>
</div>
</aside>
</div>
</Transition>
</template>