186 lines
6.6 KiB
Vue
186 lines
6.6 KiB
Vue
<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>
|