feat: 新增移动设备页内headers导航

This commit is contained in:
pengzhanbo 2023-12-23 01:49:39 +08:00
parent 8f2507bbb2
commit 1db07c4215
3 changed files with 267 additions and 8 deletions

View File

@ -0,0 +1,57 @@
<script setup lang="ts">
import type { PageHeader } from '@vuepress/client'
defineProps<{
headers: PageHeader[]
root?: boolean
}>()
function onClick({ target: el }: Event) {
const id = (el as HTMLAnchorElement).href!.split('#')[1]
const heading = document.getElementById(decodeURIComponent(id))
heading?.focus({ preventScroll: true })
}
</script>
<template>
<ul :class="root ? 'root' : 'nested'">
<li v-for="{ children, link, title } in headers" :key="link">
<a class="outline-link" :href="link" :title="title" @click="onClick">{{ title }}</a>
<template v-if="children?.length">
<DocOutlineItem :headers="children" />
</template>
</li>
</ul>
</template>
<style scoped>
.root {
position: relative;
z-index: 1;
}
.nested {
padding-left: 16px;
}
.outline-link {
display: block;
line-height: 28px;
color: var(--vp-c-text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.5s;
font-weight: 400;
}
.outline-link:hover,
.outline-link.active {
color: var(--vp-c-text-1);
transition: color 0.25s;
}
.outline-link.nested {
padding-left: 13px;
}
</style>

View File

@ -1,6 +1,13 @@
<script lang="ts" setup>
import { useSidebar } from '../composables/index.js'
import { usePageData } from '@vuepress/client'
import { useWindowScroll } from '@vueuse/core'
import { computed, onMounted, ref } from 'vue'
import type {
PlumeThemePageData,
} from '../../shared/index.js'
import { useSidebar, useThemeLocaleData } from '../composables/index.js'
import IconAlignLeft from './icons/IconAlignLeft.vue'
import LocalNavOutlineDropdown from './LocalNavOutlineDropdown.vue'
defineProps<{
open: boolean
@ -8,15 +15,39 @@ defineProps<{
defineEmits<(e: 'open-menu') => void>()
const { hasSidebar } = useSidebar()
const page = usePageData<PlumeThemePageData>()
const themeData = useThemeLocaleData()
const { hasSidebar } = useSidebar()
const { y } = useWindowScroll()
const navHeight = ref(0)
const headers = computed(() => page.value.headers)
const empty = computed(() => {
return headers.value.length === 0 && !hasSidebar.value
})
onMounted(() => {
navHeight.value = parseInt(
getComputedStyle(document.documentElement).getPropertyValue(
'--vp-nav-height'
)
)
})
const classes = computed(() => {
return {
'local-nav': true,
fixed: empty.value,
'reached-top': y.value >= navHeight.value
}
})
function scrollToTop() {
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
}
</script>
<template>
<div v-if="hasSidebar" class="local-nav">
<div v-if="hasSidebar && (!empty || y >= navHeight)" :class="classes">
<button
class="menu"
:aria-expanded="open"
@ -24,10 +55,10 @@ function scrollToTop() {
@click="$emit('open-menu')"
>
<IconAlignLeft class="menu-icon" />
<span class="menu-text"> Menu </span>
<span class="menu-text"> {{ themeData.sidebarMenuLabel || 'Menu' }} </span>
</button>
<a class="top-link" href="#" @click="scrollToTop"> Return to top </a>
<LocalNavOutlineDropdown :headers="headers" :nav-height="navHeight" />
</div>
</template>
@ -48,6 +79,14 @@ function scrollToTop() {
transition: border-color 0.5s, background-color 0.5s;
}
.local-nav.fixed {
position: fixed;
}
.local-nav.reached-top {
border-top-color: transparent;
}
@media (min-width: 960px) {
.local-nav {
display: none;

View File

@ -0,0 +1,163 @@
<script setup lang="ts">
import type {PageHeader} from '@vuepress/client';
import {onClickOutside} from '@vueuse/core'
import { nextTick, ref, watch } from 'vue'
import { useThemeLocaleData } from '../composables/index.js'
import DocOutlineItem from './DocOutlineItem.vue'
import IconChevronRight from './icons/IconChevronRight.vue'
const props = defineProps<{
headers: PageHeader[]
navHeight: number
}>()
const theme = useThemeLocaleData()
const open = ref(false)
const vh = ref(0)
const items = ref<HTMLDivElement>()
const btn = ref<HTMLButtonElement>()
watch(() => props.headers, () => {
open.value = false
})
onClickOutside(items, () => {
open.value = false
}, { ignore: [btn] })
function toggle() {
open.value = !open.value
vh.value = window.innerHeight + Math.min(window.scrollY - props.navHeight, 0)
}
function onItemClick(e: Event) {
if ((e.target as HTMLElement).classList.contains('outline-link')) {
// disable animation on hash navigation when page jumps
if (items.value) {
items.value.style.transition = 'none'
}
nextTick(() => {
open.value = false
})
}
}
function scrollToTop() {
open.value = false
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
}
</script>
<template>
<div class="local-nav-outline-dropdown" :style="{ '--vp-vh': vh + 'px' }">
<button v-if="headers.length > 0" ref="btn" :class="{ open }" @click="toggle">
{{ 'On this page' }}
<IconChevronRight class="icon" />
</button>
<button v-else @click="scrollToTop">
{{ theme.returnToTopLabel || 'Return to top' }}
</button>
<Transition name="flyout">
<div v-if="open" ref="items" class="items" @click="onItemClick">
<div class="header">
<a class="top-link" href="#" @click="scrollToTop">
{{ theme.returnToTopLabel || 'Return to top' }}
</a>
</div>
<div class="outline">
<DocOutlineItem :headers="headers" />
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.local-nav-outline-dropdown {
padding: 12px 20px 11px;
}
.local-nav-outline-dropdown button {
display: block;
font-size: 12px;
font-weight: 500;
line-height: 24px;
color: var(--vp-c-text-2);
transition: color 0.5s;
position: relative;
}
.local-nav-outline-dropdown button:hover {
color: var(--vp-c-text-1);
transition: color 0.25s;
}
.local-nav-outline-dropdown button.open {
color: var(--vp-c-text-1);
}
.icon {
display: inline-block;
vertical-align: middle;
margin-left: 2px;
width: 14px;
height: 14px;
fill: currentColor;
}
:deep(.outline-link) {
font-size: 14px;
padding: 2px 0;
}
.open>.icon {
transform: rotate(90deg);
}
.items {
position: absolute;
top: 64px;
right: 16px;
left: 16px;
display: grid;
/* gap: 1px; */
border: 1px solid var(--vp-c-border);
border-radius: 8px;
background-color: var(--vp-c-gutter);
max-height: calc(var(--vp-vh, 100vh) - 86px);
overflow: hidden auto;
box-shadow: var(--vp-shadow-3);
}
.header {
background-color: var(--vp-c-bg-soft);
}
.top-link {
display: block;
padding: 0 16px;
line-height: 48px;
font-size: 14px;
font-weight: 500;
color: var(--vp-c-brand-1);
}
.outline {
padding: 8px 0;
background-color: var(--vp-c-bg-soft);
}
.flyout-enter-active {
transition: all .2s ease-out;
}
.flyout-leave-active {
transition: all .15s ease-in;
}
.flyout-enter-from,
.flyout-leave-to {
opacity: 0;
transform: translateY(-16px);
}
</style>