feat(theme): new design for local nav and global header (#3359)

Co-authored-by: Divyansh Singh <40380293+brc-dd@users.noreply.github.com>
pull/3391/head
Kia King Ishii 6 months ago committed by GitHub
parent 621254300d
commit d10bf42c26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -5,7 +5,6 @@ import { useData } from '../composables/data'
import { useSidebar } from '../composables/sidebar'
import VPDocAside from './VPDocAside.vue'
import VPDocFooter from './VPDocFooter.vue'
import VPDocOutlineDropdown from './VPDocOutlineDropdown.vue'
const { theme } = useData()
@ -43,7 +42,6 @@ const pageName = computed(() =>
<div class="content">
<div class="content-container">
<slot name="doc-before" />
<VPDocOutlineDropdown />
<main class="main">
<Content
class="vp-doc"
@ -70,16 +68,6 @@ const pageName = computed(() =>
width: 100%;
}
.VPDoc .VPDocOutlineDropdown {
display: none;
}
@media (min-width: 960px) and (max-width: 1279px) {
.VPDoc .VPDocOutlineDropdown {
display: block;
}
}
@media (min-width: 768px) {
.VPDoc {
padding: 48px 32px 128px;
@ -88,7 +76,7 @@ const pageName = computed(() =>
@media (min-width: 960px) {
.VPDoc {
padding: 32px 32px 0;
padding: 48px 32px 0;
}
.VPDoc:not(.has-sidebar) .container {
@ -147,7 +135,7 @@ const pageName = computed(() =>
.aside-container {
position: fixed;
top: 0;
padding-top: calc(var(--vp-nav-height) + var(--vp-layout-top-height, 0px) + var(--vp-doc-top-height, 0px) + 32px);
padding-top: calc(var(--vp-nav-height) + var(--vp-layout-top-height, 0px) + var(--vp-doc-top-height, 0px) + 48px);
width: 224px;
height: 100vh;
overflow-x: hidden;
@ -171,7 +159,7 @@ const pageName = computed(() =>
.aside-content {
display: flex;
flex-direction: column;
min-height: calc(100vh - (var(--vp-nav-height) + var(--vp-layout-top-height, 0px) + 32px));
min-height: calc(100vh - (var(--vp-nav-height) + var(--vp-layout-top-height, 0px) + 48px));
padding-bottom: 32px;
}

@ -80,9 +80,8 @@ useActiveAnchor(container, marker)
}
.outline-title {
letter-spacing: 0.4px;
line-height: 28px;
font-size: 13px;
line-height: 32px;
font-size: 14px;
font-weight: 600;
}
</style>

@ -1,85 +0,0 @@
<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import { useData } from '../composables/data'
import { getHeaders, resolveTitle, type MenuItem } from '../composables/outline'
import VPDocOutlineItem from './VPDocOutlineItem.vue'
import { onContentUpdated } from 'vitepress'
import VPIconChevronRight from './icons/VPIconChevronRight.vue'
const { frontmatter, theme } = useData()
const open = ref(false)
onContentUpdated(() => {
open.value = false
})
const headers = shallowRef<MenuItem[]>([])
onContentUpdated(() => {
headers.value = getHeaders(
frontmatter.value.outline ?? theme.value.outline
)
})
</script>
<template>
<div class="VPDocOutlineDropdown" v-if="headers.length > 0">
<button @click="open = !open" :class="{ open }">
{{ resolveTitle(theme) }}
<VPIconChevronRight class="icon" />
</button>
<div class="items" v-if="open">
<VPDocOutlineItem :headers="headers" />
</div>
</div>
</template>
<style scoped>
.VPDocOutlineDropdown {
margin-bottom: 48px;
}
.VPDocOutlineDropdown button {
display: block;
font-size: 14px;
font-weight: 500;
line-height: 24px;
border: 1px solid var(--vp-c-border);
padding: 4px 12px;
color: var(--vp-c-text-2);
background-color: var(--vp-c-default-soft);
border-radius: 8px;
transition: color 0.5s;
}
.VPDocOutlineDropdown button:hover {
color: var(--vp-c-text-1);
transition: color 0.25s;
}
.VPDocOutlineDropdown button.open {
color: var(--vp-c-text-1);
}
.icon {
display: inline-block;
vertical-align: middle;
width: 16px;
height: 16px;
fill: currentColor;
}
:deep(.outline-link) {
font-size: 14px;
font-weight: 400;
}
.open > .icon {
transform: rotate(90deg);
}
.items {
margin-top: 12px;
border-left: 1px solid var(--vp-c-divider);
}
</style>

@ -14,7 +14,7 @@ function onClick({ target: el }: Event) {
</script>
<template>
<ul :class="root ? 'root' : 'nested'">
<ul class="VPDocOutlineItem" :class="root ? 'root' : 'nested'">
<li v-for="{ children, link, title } in headers">
<a class="outline-link" :href="link" @click="onClick" :title="title">{{ title }}</a>
<template v-if="children?.length">
@ -31,18 +31,20 @@ function onClick({ target: el }: Event) {
}
.nested {
padding-right: 16px;
padding-left: 16px;
}
.outline-link {
display: block;
line-height: 28px;
line-height: 32px;
font-size: 14px;
font-weight: 400;
color: var(--vp-c-text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.5s;
font-weight: 400;
}
.outline-link:hover,

@ -1,9 +1,10 @@
<script lang="ts" setup>
import { useWindowScroll } from '@vueuse/core'
import { onContentUpdated } from 'vitepress'
import { computed, onMounted, ref, shallowRef } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useData } from '../composables/data'
import { getHeaders, type MenuItem } from '../composables/outline'
import { useLocalNav } from '../composables/local-nav'
import { getHeaders } from '../composables/outline'
import { useSidebar } from '../composables/sidebar'
import VPLocalNavOutlineDropdown from './VPLocalNavOutlineDropdown.vue'
import VPIconAlignLeft from './icons/VPIconAlignLeft.vue'
@ -18,9 +19,9 @@ defineEmits<{
const { theme, frontmatter } = useData()
const { hasSidebar } = useSidebar()
const { headers } = useLocalNav()
const { y } = useWindowScroll()
const headers = shallowRef<MenuItem[]>([])
const navHeight = ref(0)
onMounted(() => {
@ -36,37 +37,44 @@ onContentUpdated(() => {
})
const empty = computed(() => {
return headers.value.length === 0 && !hasSidebar.value
return headers.value.length === 0
})
const emptyAndNoSidebar = computed(() => {
return empty.value && !hasSidebar.value
})
const classes = computed(() => {
return {
VPLocalNav: true,
fixed: empty.value,
'reached-top': y.value >= navHeight.value
'has-sidebar': hasSidebar.value,
empty: empty.value,
fixed: emptyAndNoSidebar.value
}
})
</script>
<template>
<div
v-if="frontmatter.layout !== 'home' && (!empty || y >= navHeight)"
v-if="frontmatter.layout !== 'home' && (!emptyAndNoSidebar || y >= navHeight)"
:class="classes"
>
<button
v-if="hasSidebar"
class="menu"
:aria-expanded="open"
aria-controls="VPSidebarNav"
@click="$emit('open-menu')"
>
<VPIconAlignLeft class="menu-icon" />
<span class="menu-text">
{{ theme.sidebarMenuLabel || 'Menu' }}
</span>
</button>
<VPLocalNavOutlineDropdown :headers="headers" :navHeight="navHeight" />
<div class="container">
<button
v-if="hasSidebar"
class="menu"
:aria-expanded="open"
aria-controls="VPSidebarNav"
@click="$emit('open-menu')"
>
<VPIconAlignLeft class="menu-icon" />
<span class="menu-text">
{{ theme.sidebarMenuLabel || 'Menu' }}
</span>
</button>
<VPLocalNavOutlineDropdown :headers="headers" :navHeight="navHeight" />
</div>
</div>
</template>
@ -77,10 +85,6 @@ const classes = computed(() => {
/*rtl:ignore*/
left: 0;
z-index: var(--vp-z-index-local-nav);
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid var(--vp-c-gutter);
border-bottom: 1px solid var(--vp-c-gutter);
padding-top: var(--vp-layout-top-height, 0px);
width: 100%;
@ -91,16 +95,38 @@ const classes = computed(() => {
position: fixed;
}
.VPLocalNav.reached-top {
border-top-color: transparent;
@media (min-width: 960px) {
.VPLocalNav {
top: var(--vp-nav-height);
}
.VPLocalNav.has-sidebar {
padding-left: var(--vp-sidebar-width);
}
.VPLocalNav.empty {
display: none;
}
}
@media (min-width: 960px) {
@media (min-width: 1280px) {
.VPLocalNav {
display: none;
}
}
@media (min-width: 1440px) {
.VPLocalNav.has-sidebar {
padding-left: calc((100vw - var(--vp-layout-max-width)) / 2 + var(--vp-sidebar-width));
}
}
.container {
display: flex;
justify-content: space-between;
align-items: center;
}
.menu {
display: flex;
align-items: center;
@ -123,6 +149,12 @@ const classes = computed(() => {
}
}
@media (min-width: 960px) {
.menu {
display: none;
}
}
.menu-icon {
margin-right: 8px;
width: 16px;

@ -1,4 +1,5 @@
<script setup lang="ts">
import { onClickOutside, onKeyStroke } from '@vueuse/core'
import { onContentUpdated } from 'vitepress'
import { nextTick, ref } from 'vue'
import { useData } from '../composables/data'
@ -14,8 +15,17 @@ const props = defineProps<{
const { theme } = useData()
const open = ref(false)
const vh = ref(0)
const main = ref<HTMLDivElement>()
const items = ref<HTMLDivElement>()
onClickOutside(main, () => {
open.value = false
})
onKeyStroke('Escape', () => {
open.value = false
})
onContentUpdated(() => {
open.value = false
})
@ -44,7 +54,11 @@ function scrollToTop() {
</script>
<template>
<div class="VPLocalNavOutlineDropdown" :style="{ '--vp-vh': vh + 'px' }">
<div
class="VPLocalNavOutlineDropdown"
:style="{ '--vp-vh': vh + 'px' }"
ref="main"
>
<button @click="toggle" :class="{ open }" v-if="headers.length > 0">
{{ resolveTitle(theme) }}
<VPIconChevronRight class="icon" />
@ -53,11 +67,7 @@ function scrollToTop() {
{{ theme.returnToTopLabel || 'Return to top' }}
</button>
<Transition name="flyout">
<div v-if="open"
ref="items"
class="items"
@click="onItemClick"
>
<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' }}
@ -76,6 +86,12 @@ function scrollToTop() {
padding: 12px 20px 11px;
}
@media (min-width: 960px) {
.VPLocalNavOutlineDropdown {
padding: 12px 36px 11px;
}
}
.VPLocalNavOutlineDropdown button {
display: block;
font-size: 12px;
@ -95,6 +111,12 @@ function scrollToTop() {
color: var(--vp-c-text-1);
}
@media (min-width: 960px) {
.VPLocalNavOutlineDropdown button {
font-size: 14px;
}
}
.icon {
display: inline-block;
vertical-align: middle;
@ -104,18 +126,13 @@ function scrollToTop() {
fill: currentColor;
}
:deep(.outline-link) {
font-size: 14px;
padding: 2px 0;
}
.open > .icon {
transform: rotate(90deg);
}
.items {
position: absolute;
top: 64px;
top: 40px;
right: 16px;
left: 16px;
display: grid;
@ -128,6 +145,14 @@ function scrollToTop() {
box-shadow: var(--vp-shadow-3);
}
@media (min-width: 960px) {
.items {
right: auto;
left: calc(var(--vp-sidebar-width) + 32px);
width: 320px;
}
}
.header {
background-color: var(--vp-c-bg-soft);
}
@ -147,11 +172,11 @@ function scrollToTop() {
}
.flyout-enter-active {
transition: all .2s ease-out;
transition: all 0.2s ease-out;
}
.flyout-leave-active {
transition: all .15s ease-in;
transition: all 0.15s ease-in;
}
.flyout-enter-from,

@ -2,6 +2,7 @@
import { useWindowScroll } from '@vueuse/core'
import { ref, watchPostEffect } from 'vue'
import { useData } from '../composables/data'
import { useLocalNav } from '../composables/local-nav'
import { useSidebar } from '../composables/sidebar'
import VPNavBarAppearance from './VPNavBarAppearance.vue'
import VPNavBarExtra from './VPNavBarExtra.vue'
@ -22,6 +23,7 @@ defineEmits<{
const { y } = useWindowScroll()
const { hasSidebar } = useSidebar()
const { hasLocalNav } = useLocalNav()
const { frontmatter } = useData()
const classes = ref<Record<string, boolean>>({})
@ -29,6 +31,7 @@ const classes = ref<Record<string, boolean>>({})
watchPostEffect(() => {
classes.value = {
'has-sidebar': hasSidebar.value,
'has-local-nav': hasLocalNav.value,
top: frontmatter.value.layout === 'home' && y.value === 0,
}
})
@ -36,59 +39,76 @@ watchPostEffect(() => {
<template>
<div class="VPNavBar" :class="classes">
<div class="container">
<div class="title">
<VPNavBarTitle>
<template #nav-bar-title-before><slot name="nav-bar-title-before" /></template>
<template #nav-bar-title-after><slot name="nav-bar-title-after" /></template>
</VPNavBarTitle>
</div>
<div class="wrapper">
<div class="container">
<div class="title">
<VPNavBarTitle>
<template #nav-bar-title-before><slot name="nav-bar-title-before" /></template>
<template #nav-bar-title-after><slot name="nav-bar-title-after" /></template>
</VPNavBarTitle>
</div>
<div class="content">
<div class="curtain" />
<div class="content-body">
<slot name="nav-bar-content-before" />
<VPNavBarSearch class="search" />
<VPNavBarMenu class="menu" />
<VPNavBarTranslations class="translations" />
<VPNavBarAppearance class="appearance" />
<VPNavBarSocialLinks class="social-links" />
<VPNavBarExtra class="extra" />
<slot name="nav-bar-content-after" />
<VPNavBarHamburger class="hamburger" :active="isScreenOpen" @click="$emit('toggle-screen')" />
<div class="content">
<div class="content-body">
<slot name="nav-bar-content-before" />
<VPNavBarSearch class="search" />
<VPNavBarMenu class="menu" />
<VPNavBarTranslations class="translations" />
<VPNavBarAppearance class="appearance" />
<VPNavBarSocialLinks class="social-links" />
<VPNavBarExtra class="extra" />
<slot name="nav-bar-content-after" />
<VPNavBarHamburger class="hamburger" :active="isScreenOpen" @click="$emit('toggle-screen')" />
</div>
</div>
</div>
</div>
<div class="divider">
<div class="divider-line" />
</div>
</div>
</template>
<style scoped>
.VPNavBar {
position: relative;
border-bottom: 1px solid transparent;
padding: 0 8px 0 24px;
height: var(--vp-nav-height);
pointer-events: none;
white-space: nowrap;
transition: background-color 0.5s;
}
@media (min-width: 768px) {
.VPNavBar {
padding: 0 32px;
}
.VPNavBar.has-local-nav {
background-color: var(--vp-nav-bg-color);
}
@media (min-width: 960px) {
.VPNavBar.has-sidebar {
padding: 0;
.VPNavBar.has-local-nav {
background-color: transparent;
}
.VPNavBar:not(.has-sidebar):not(.top) {
border-bottom-color: var(--vp-c-gutter);
background-color: var(--vp-nav-bg-color);
}
}
.wrapper {
padding: 0 8px 0 24px;
}
@media (min-width: 768px) {
.wrapper {
padding: 0 32px;
}
}
@media (min-width: 960px) {
.VPNavBar.has-sidebar .wrapper {
padding: 0;
}
}
.container {
display: flex;
justify-content: space-between;
@ -163,15 +183,19 @@ watchPostEffect(() => {
display: flex;
justify-content: flex-end;
align-items: center;
height: calc(var(--vp-nav-height) - 1px);
height: var(--vp-nav-height);
transition: background-color 0.5s;
}
@media (min-width: 960px) {
.VPNavBar:not(.top) .content-body{
.VPNavBar:not(.top) .content-body {
position: relative;
background-color: var(--vp-nav-bg-color);
}
.VPNavBar:not(.has-sidebar):not(.top) .content-body {
background-color: transparent;
}
}
@media (max-width: 767px) {
@ -206,27 +230,40 @@ watchPostEffect(() => {
margin-right: -8px;
}
.divider {
width: 100%;
height: 1px;
}
@media (min-width: 960px) {
.VPNavBar.has-sidebar .curtain {
position: absolute;
right: 0;
bottom: -31px;
width: calc(100% - var(--vp-sidebar-width));
height: 32px;
.VPNavBar.has-sidebar .divider {
padding-left: var(--vp-sidebar-width);
}
}
.VPNavBar.has-sidebar .curtain::before {
display: block;
width: 100%;
height: 32px;
background: linear-gradient(var(--vp-c-bg), transparent 70%);
content: "";
@media (min-width: 1440px) {
.VPNavBar.has-sidebar .divider {
padding-left: calc((100vw - var(--vp-layout-max-width)) / 2 + var(--vp-sidebar-width));
}
}
@media (min-width: 1440px) {
.VPNavBar.has-sidebar .curtain {
width: calc(100% - ((100vw - var(--vp-layout-max-width)) / 2 + var(--vp-sidebar-width)));
.divider-line {
width: 100%;
height: 1px;
transition: background-color 0.5s;
}
.VPNavBar.has-local-nav .divider-line {
background-color: var(--vp-c-gutter);
}
@media (min-width: 960px) {
.VPNavBar:not(.top) .divider-line {
background-color: var(--vp-c-gutter);
}
.VPNavBar:not(.has-sidebar):not(.top) .divider {
background-color: var(--vp-c-gutter);
}
}
</style>

@ -87,7 +87,6 @@ watch(
@media (min-width: 960px) {
.VPSidebar {
z-index: 1;
padding-top: var(--vp-nav-height);
width: var(--vp-sidebar-width);
max-width: 100%;

@ -0,0 +1,24 @@
import { onContentUpdated } from 'vitepress'
import { type DefaultTheme } from 'vitepress/theme'
import { computed, shallowRef } from 'vue'
import { getHeaders, type MenuItem } from '../composables/outline'
import { useData } from './data'
export function useLocalNav(): DefaultTheme.DocLocalNav {
const { theme, frontmatter } = useData()
const headers = shallowRef<MenuItem[]>([])
const hasLocalNav = computed(() => {
return headers.value.length > 0
})
onContentUpdated(() => {
headers.value = getHeaders(frontmatter.value.outline ?? theme.value.outline)
})
return {
headers,
hasLocalNav
}
}

@ -181,7 +181,7 @@ export function useActiveAnchor(
if (activeLink) {
activeLink.classList.add('active')
marker.value.style.top = activeLink.offsetTop + 33 + 'px'
marker.value.style.top = activeLink.offsetTop + 39 + 'px'
marker.value.style.opacity = '1'
} else {
marker.value.style.top = '33px'

@ -264,6 +264,12 @@
--vp-z-index-sidebar: 60;
}
@media (min-width: 960px) {
:root {
--vp-z-index-sidebar: 25;
}
}
/**
* Icons
* -------------------------------------------------------------------------- */

@ -27,6 +27,7 @@ export { default as VPTeamPageSection } from './components/VPTeamPageSection.vue
export { default as VPTeamMembers } from './components/VPTeamMembers.vue'
export { useSidebar } from './composables/sidebar'
export { useLocalNav } from './composables/local-nav'
const theme: Theme = {
Layout,

@ -252,7 +252,7 @@ export async function resolveSiteData(
appearance: userConfig.appearance ?? true,
themeConfig: userConfig.themeConfig || {},
locales: userConfig.locales || {},
scrollOffset: userConfig.scrollOffset ?? 90,
scrollOffset: userConfig.scrollOffset ?? 134,
cleanUrls: !!userConfig.cleanUrls,
contentProps: userConfig.contentProps
}

1
theme.d.ts vendored

@ -13,6 +13,7 @@ declare const theme: {
export default theme
export declare const useSidebar: () => DefaultTheme.DocSidebar
export declare const useLocalNav: () => DefaultTheme.DocLocalNav
// TODO: add props for these
export declare const VPButton: DefineComponent

@ -358,6 +358,25 @@ export namespace DefaultTheme {
actionText?: string
}
// local nav -----------------------------------------------------------------
/**
* ReturnType of `useLocalNav`.
*/
export interface DocLocalNav {
/**
* The outline headers of the current page.
*/
headers: ShallowRef<MenuItem[]>
/**
* Whether the current page has a local nav. Local nav is shown when the
* "outline" is present in the page. However, note that the actual
* local nav visibility depends on the screen width as well.
*/
hasLocalNav: ComputedRef<boolean>
}
// outline -------------------------------------------------------------------
export interface Outline {

Loading…
Cancel
Save