mirror of
https://github.com/pengzhanbo/vuepress-theme-plume.git
synced 2026-04-23 10:58:13 +08:00
feat: 新增移动设备页内headers导航
This commit is contained in:
parent
8f2507bbb2
commit
1db07c4215
57
packages/theme/src/client/components/DocOutlineItem.vue
Normal file
57
packages/theme/src/client/components/DocOutlineItem.vue
Normal 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>
|
||||
@ -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;
|
||||
|
||||
163
packages/theme/src/client/components/LocalNavOutlineDropdown.vue
Normal file
163
packages/theme/src/client/components/LocalNavOutlineDropdown.vue
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user