feat(theme): add sidebar collapse functionality

pull/5105/head
Artea 7 days ago
parent f8d8c0d712
commit b2ed4d6ada

@ -9,7 +9,7 @@ import VPSidebar from './components/VPSidebar.vue'
import VPSkipLink from './components/VPSkipLink.vue'
import { useData } from './composables/data'
import { layoutInfoInjectionKey, registerWatchers } from './composables/layout'
import { useSidebarControl } from './composables/sidebar'
import { useSidebarControl, useSidebarCollapse } from './composables/sidebar'
const {
isOpen: isSidebarOpen,
@ -17,6 +17,8 @@ const {
close: closeSidebar
} = useSidebarControl()
const { isCollapsed: isSidebarCollapsed } = useSidebarCollapse()
registerWatchers({ closeSidebar })
const { frontmatter } = useData()
@ -31,7 +33,7 @@ provide(layoutInfoInjectionKey, { heroImageSlotExists })
<div
v-if="frontmatter.layout !== false"
class="Layout"
:class="frontmatter.pageClass"
:class="[frontmatter.pageClass, { 'sidebar-collapsed': isSidebarCollapsed }]"
>
<slot name="layout-top" />
<VPSkipLink />

@ -2,19 +2,21 @@
import NotFound from '../NotFound.vue'
import { useData } from '../composables/data'
import { useLayout } from '../composables/layout'
import { useSidebarCollapse } from '../composables/sidebar'
import VPDoc from './VPDoc.vue'
import VPHome from './VPHome.vue'
import VPPage from './VPPage.vue'
const { page, frontmatter } = useData()
const { isHome, hasSidebar } = useLayout()
const { isCollapsed } = useSidebarCollapse()
</script>
<template>
<div
class="VPContent"
id="VPContent"
:class="{ 'has-sidebar': hasSidebar, 'is-home': isHome }"
:class="{ 'has-sidebar': hasSidebar, 'is-home': isHome, 'sidebar-collapsed': isCollapsed }"
>
<slot name="not-found" v-if="page.isNotFound"><NotFound /></slot>
@ -83,6 +85,11 @@ const { isHome, hasSidebar } = useLayout()
.VPContent.has-sidebar {
margin: var(--vp-layout-top-height, 0px) 0 0;
padding-left: var(--vp-sidebar-width);
transition: padding-left 0.25s;
}
.VPContent.has-sidebar.sidebar-collapsed {
padding-left: 0;
}
}
@ -91,5 +98,9 @@ const { isHome, hasSidebar } = useLayout()
padding-right: calc((100% - var(--vp-layout-max-width)) / 2);
padding-left: calc((100% - var(--vp-layout-max-width)) / 2 + var(--vp-sidebar-width));
}
.VPContent.has-sidebar.sidebar-collapsed {
padding-left: calc((100% - var(--vp-layout-max-width)) / 2);
}
}
</style>

@ -3,6 +3,7 @@ import { useWindowScroll } from '@vueuse/core'
import { computed, onMounted, ref } from 'vue'
import { useData } from '../composables/data'
import { useLayout } from '../composables/layout'
import { useSidebarCollapse } from '../composables/sidebar'
import VPLocalNavOutlineDropdown from './VPLocalNavOutlineDropdown.vue'
defineProps<{
@ -15,6 +16,7 @@ defineEmits<{
const { theme } = useData()
const { isHome, hasSidebar, headers, hasLocalNav } = useLayout()
const { isCollapsed } = useSidebarCollapse()
const { y } = useWindowScroll()
const navHeight = ref(0)
@ -31,6 +33,7 @@ const classes = computed(() => {
return {
VPLocalNav: true,
'has-sidebar': hasSidebar.value,
'sidebar-collapsed': isCollapsed.value,
empty: !hasLocalNav.value,
fixed: !hasLocalNav.value && !hasSidebar.value
}
@ -85,6 +88,11 @@ const classes = computed(() => {
.VPLocalNav.has-sidebar {
padding-left: var(--vp-sidebar-width);
transition: padding-left 0.3s ease;
}
.VPLocalNav.has-sidebar.sidebar-collapsed {
padding-left: 0;
}
.VPLocalNav.empty {

@ -2,6 +2,7 @@
import { useWindowScroll } from '@vueuse/core'
import { ref, watchPostEffect } from 'vue'
import { useLayout } from '../composables/layout'
import { useSidebarCollapse } from '../composables/sidebar'
import VPNavBarAppearance from './VPNavBarAppearance.vue'
import VPNavBarExtra from './VPNavBarExtra.vue'
import VPNavBarHamburger from './VPNavBarHamburger.vue'
@ -21,17 +22,29 @@ defineEmits<{
const { y } = useWindowScroll()
const { isHome, hasSidebar } = useLayout()
const { isCollapsed, expand } = useSidebarCollapse()
const searchRef = ref<InstanceType<typeof VPNavBarSearch> | null>(null)
const classes = ref<Record<string, boolean>>({})
watchPostEffect(() => {
classes.value = {
'has-sidebar': hasSidebar.value,
'sidebar-collapsed': isCollapsed.value,
'home': isHome.value,
'top': y.value === 0,
'screen-open': props.isScreenOpen
}
})
function handleExpand() {
expand()
}
function handleCapsuleSearch() {
searchRef.value?.openSearch()
}
</script>
<template>
@ -43,12 +56,31 @@ watchPostEffect(() => {
<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 v-if="hasSidebar && isCollapsed" class="expand-capsule">
<button
class="capsule-btn expand-btn"
@click="handleExpand"
aria-label="Expand sidebar"
title="Expand sidebar"
>
<span class="vpi-sidebar-expand capsule-icon" />
</button>
<div class="capsule-divider" />
<button
class="capsule-btn search-btn"
@click="handleCapsuleSearch"
aria-label="Search"
title="Search"
>
<span class="vpi-search capsule-icon" />
</button>
</div>
</div>
<div class="content">
<div class="content-body">
<slot name="nav-bar-content-before" />
<VPNavBarSearch class="search" />
<VPNavBarSearch ref="searchRef" :icon-only="hasSidebar && isCollapsed" class="search" />
<VPNavBarMenu class="menu" />
<VPNavBarTranslations class="translations" />
<VPNavBarAppearance class="appearance" />
@ -140,6 +172,8 @@ watchPostEffect(() => {
flex-shrink: 0;
height: calc(var(--vp-nav-height) - 1px);
transition: background-color 0.5s;
display: flex;
align-items: center;
}
@media (min-width: 960px) {
@ -152,6 +186,16 @@ watchPostEffect(() => {
width: var(--vp-sidebar-width);
height: var(--vp-nav-height);
background-color: transparent;
transition: width 0.3s ease, padding 0.3s ease;
}
.VPNavBar.has-sidebar.sidebar-collapsed .title {
padding-left: 24px;
width: auto;
}
.VPNavBar.has-sidebar.sidebar-collapsed .title :deep(.VPNavBarTitle) {
display: none;
}
}
@ -160,6 +204,15 @@ watchPostEffect(() => {
padding-left: max(32px, calc((100% - (var(--vp-layout-max-width) - 64px)) / 2));
width: calc((100% - (var(--vp-layout-max-width) - 64px)) / 2 + var(--vp-sidebar-width) - 32px);
}
.VPNavBar.has-sidebar.sidebar-collapsed .title {
padding-left: 24px;
width: auto;
}
.VPNavBar.has-sidebar.sidebar-collapsed .title :deep(.VPNavBarTitle) {
display: none;
}
}
.content {
@ -172,6 +225,12 @@ watchPostEffect(() => {
z-index: 1;
padding-left: var(--vp-sidebar-width);
padding-right: 32px;
transition: padding-left 0.3s ease;
}
.VPNavBar.has-sidebar.sidebar-collapsed .content {
padding-left: 0;
background-color: var(--vp-nav-bg-color);
}
}
@ -180,6 +239,11 @@ watchPostEffect(() => {
padding-left: calc((100% - var(--vp-layout-max-width)) / 2 + var(--vp-sidebar-width));
padding-right: calc((100% - var(--vp-layout-max-width)) / 2 + 32px);
}
.VPNavBar.has-sidebar.sidebar-collapsed .content {
padding-left: 0;
background-color: var(--vp-nav-bg-color);
}
}
.content-body {
@ -204,6 +268,11 @@ watchPostEffect(() => {
margin-right: -100vw;
padding-right: 100vw;
}
.VPNavBar.has-sidebar.sidebar-collapsed .content-body {
margin-left: -32px;
padding-left: 32px;
}
}
@media (max-width: 767px) {
@ -238,6 +307,55 @@ watchPostEffect(() => {
margin-right: -8px;
}
/* Sidebar expand capsule - only show on desktop */
.expand-capsule {
display: none;
}
@media (min-width: 960px) {
.expand-capsule {
display: flex;
align-items: center;
border: 1px solid var(--vp-c-divider);
border-radius: 20px;
background: var(--vp-c-bg);
padding: 4px;
margin-left: 12px;
gap: 2px;
box-shadow: var(--vp-shadow-1);
}
.capsule-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 50%;
background: transparent;
color: var(--vp-c-text-2);
cursor: pointer;
transition: color 0.25s, background-color 0.25s;
}
.capsule-btn:hover {
color: var(--vp-c-text-1);
background: var(--vp-c-default-soft);
}
.capsule-icon {
width: 18px;
height: 18px;
}
.capsule-divider {
width: 1px;
height: 16px;
background: var(--vp-c-divider);
}
}
.divider {
width: 100%;
height: 1px;
@ -246,12 +364,25 @@ watchPostEffect(() => {
@media (min-width: 960px) {
.VPNavBar.has-sidebar .divider {
padding-left: var(--vp-sidebar-width);
transition: padding-left 0.3s ease;
}
.VPNavBar.has-sidebar.sidebar-collapsed .divider {
padding-left: 0;
}
.VPNavBar.has-sidebar.sidebar-collapsed .divider-line {
background-color: var(--vp-c-gutter);
}
}
@media (min-width: 1440px) {
.VPNavBar.has-sidebar .divider {
padding-left: calc((100% - var(--vp-layout-max-width)) / 2 + var(--vp-sidebar-width));
padding-left: calc((100% - (var(--vp-layout-max-width) - 64px)) / 2 + var(--vp-sidebar-width) - 32px);
}
.VPNavBar.has-sidebar.sidebar-collapsed .divider {
padding-left: 0;
}
}

@ -2,10 +2,14 @@
import '@docsearch/css'
import { onKeyStroke } from '@vueuse/core'
import type { DefaultTheme } from 'vitepress/theme'
import { defineAsyncComponent, onMounted, onUnmounted, ref } from 'vue'
import { defineAsyncComponent, onMounted, onUnmounted, ref, watch } from 'vue'
import { useData } from '../composables/data'
import VPNavBarSearchButton from './VPNavBarSearchButton.vue'
defineProps<{
iconOnly?: boolean
}>()
const VPLocalSearchBox = __VP_LOCAL_SEARCH__
? defineAsyncComponent(() => import('./VPLocalSearchBox.vue'))
: () => null
@ -21,6 +25,7 @@ const { theme } = useData()
// hit the hotkey to invoke it.
const loaded = ref(false)
const actuallyLoaded = ref(false)
const pendingSearch = ref(false)
const preconnect = () => {
const id = 'VPAlgoliaPreconnect'
@ -66,6 +71,14 @@ onMounted(() => {
onUnmounted(remove)
})
function triggerAlgoliaSearch() {
const e = new KeyboardEvent('keydown', {
key: 'k',
metaKey: true,
})
window.dispatchEvent(e)
}
function load() {
if (!loaded.value) {
loaded.value = true
@ -75,12 +88,7 @@ function load() {
function poll() {
// programmatically open the search box after initialize
const e = new Event('keydown') as any
e.key = 'k'
e.metaKey = true
window.dispatchEvent(e)
triggerAlgoliaSearch()
setTimeout(() => {
if (!document.querySelector('.DocSearch-Modal')) {
@ -122,17 +130,39 @@ if (__VP_LOCAL_SEARCH__) {
}
const provider = __ALGOLIA__ ? 'algolia' : __VP_LOCAL_SEARCH__ ? 'local' : ''
watch(actuallyLoaded, (value) => {
if (value && pendingSearch.value) {
pendingSearch.value = false
poll()
}
})
function openSearch() {
if (__VP_LOCAL_SEARCH__) {
showSearch.value = true
} else if (__ALGOLIA__) {
if (actuallyLoaded.value) {
triggerAlgoliaSearch()
} else {
pendingSearch.value = true
loaded.value = true
}
}
}
defineExpose({ openSearch })
</script>
<template>
<div class="VPNavBarSearch">
<div class="VPNavBarSearch" :class="{ 'icon-only': iconOnly }">
<template v-if="provider === 'local'">
<VPLocalSearchBox
v-if="showSearch"
@close="showSearch = false"
/>
<div id="local-search">
<div v-if="!iconOnly" id="local-search">
<VPNavBarSearchButton @click="showSearch = true" />
</div>
</template>
@ -144,7 +174,7 @@ const provider = __ALGOLIA__ ? 'algolia' : __VP_LOCAL_SEARCH__ ? 'local' : ''
@vue:beforeMount="actuallyLoaded = true"
/>
<div v-if="!actuallyLoaded" id="docsearch">
<div v-if="!actuallyLoaded && !iconOnly" id="docsearch">
<VPNavBarSearchButton @click="load" />
</div>
</template>
@ -169,4 +199,13 @@ const provider = __ALGOLIA__ ? 'algolia' : __VP_LOCAL_SEARCH__ ? 'local' : ''
padding-left: 32px;
}
}
.VPNavBarSearch.icon-only {
flex-grow: 0;
padding-left: 0;
}
.VPNavBarSearch.icon-only #docsearch {
display: none;
}
</style>

@ -3,12 +3,14 @@ import { computed } from 'vue'
import { useData } from '../composables/data'
import { useLangs } from '../composables/langs'
import { useLayout } from '../composables/layout'
import { useSidebarCollapse } from '../composables/sidebar'
import { normalizeLink } from '../support/utils'
import VPImage from './VPImage.vue'
const { site, theme } = useData()
const { hasSidebar } = useLayout()
const { hasSidebar, isSidebarEnabled } = useLayout()
const { currentLang } = useLangs()
const { isCollapsed, collapse } = useSidebarCollapse()
const link = computed(() =>
typeof theme.value.logoLink === 'string'
@ -27,31 +29,63 @@ const target = computed(() =>
? undefined
: theme.value.logoLink?.target
)
const showCollapseButton = computed(() => {
return hasSidebar.value && isSidebarEnabled.value && !isCollapsed.value
})
function handleCollapse() {
collapse()
}
</script>
<template>
<div class="VPNavBarTitle" :class="{ 'has-sidebar': hasSidebar }">
<a
class="title"
:href="link ?? normalizeLink(currentLang.link)"
:rel
:target
>
<slot name="nav-bar-title-before" />
<VPImage v-if="theme.logo" class="logo" :image="theme.logo" />
<span v-if="theme.siteTitle" v-html="theme.siteTitle"></span>
<span v-else-if="theme.siteTitle === undefined">{{ site.title }}</span>
<slot name="nav-bar-title-after" />
</a>
<div class="VPNavBarTitle" :class="{ 'has-sidebar': hasSidebar, 'sidebar-collapsed': isCollapsed }">
<template v-if="!isCollapsed || !isSidebarEnabled">
<a
class="title"
:href="link ?? normalizeLink(currentLang.link)"
:rel
:target
>
<slot name="nav-bar-title-before" />
<VPImage v-if="theme.logo" class="logo" :image="theme.logo" />
<span v-if="theme.siteTitle" v-html="theme.siteTitle"></span>
<span v-else-if="theme.siteTitle === undefined">{{ site.title }}</span>
<slot name="nav-bar-title-after" />
</a>
<button
v-if="showCollapseButton"
class="collapse-btn"
@click="handleCollapse"
aria-label="collapse sidebar"
title="collapse sidebar"
>
<span class="vpi-sidebar-collapse collapse-icon" />
</button>
</template>
</div>
</template>
<style scoped>
.title {
.VPNavBarTitle {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
height: var(--vp-nav-height);
border-bottom: 1px solid transparent;
width: 100%;
}
@media (min-width: 960px) {
.VPNavBarTitle.has-sidebar {
border-bottom-color: var(--vp-c-divider);
}
}
.title {
display: flex;
align-items: center;
height: var(--vp-nav-height);
font-size: 16px;
font-weight: 600;
@ -63,14 +97,45 @@ const target = computed(() =>
.title {
flex-shrink: 0;
}
.VPNavBarTitle.has-sidebar .title {
border-bottom-color: var(--vp-c-divider);
}
}
:deep(.logo) {
margin-right: 8px;
height: var(--vp-nav-logo-height);
}
.collapse-btn {
display: none;
width: 28px;
height: 28px;
border-radius: 6px;
background-color: transparent;
border: 1px solid var(--vp-c-divider);
cursor: pointer;
transition: all 0.25s;
color: var(--vp-c-text-2);
}
@media (min-width: 960px) {
.collapse-btn {
display: flex;
align-items: center;
justify-content: center;
}
}
.collapse-btn:hover {
background-color: var(--vp-c-bg-soft);
border-color: var(--vp-c-brand-1);
color: var(--vp-c-brand-1);
}
.collapse-btn:active {
transform: scale(0.95);
}
.collapse-icon {
width: 18px;
height: 18px;
}
</style>

@ -3,9 +3,11 @@ import { useScrollLock } from '@vueuse/core'
import { inBrowser } from 'vitepress'
import { ref, watch } from 'vue'
import { useLayout } from '../composables/layout'
import { useSidebarCollapse } from '../composables/sidebar'
import VPSidebarGroup from './VPSidebarGroup.vue'
const { sidebarGroups, hasSidebar } = useLayout()
const { isCollapsed } = useSidebarCollapse()
const props = defineProps<{
open: boolean
@ -41,7 +43,7 @@ watch(
<aside
v-if="hasSidebar"
class="VPSidebar"
:class="{ open }"
:class="{ open, collapsed: isCollapsed }"
ref="navEl"
@click.stop
>
@ -80,7 +82,7 @@ watch(
overflow-x: hidden;
overflow-y: auto;
transform: translateX(-100%);
transition: opacity 0.5s, transform 0.25s ease;
transition: opacity 0.5s, transform 0.25s ease, width 0.3s ease;
overscroll-behavior: contain;
}
@ -107,6 +109,13 @@ watch(
box-shadow: none;
transform: translateX(0);
}
.VPSidebar.collapsed {
transform: translateX(calc(-1 * var(--vp-sidebar-width)));
opacity: 0;
visibility: hidden;
pointer-events: none;
}
}
@media (min-width: 1440px) {

@ -14,6 +14,7 @@ import { hasActiveLink as containsActiveLink } from '../support/sidebar'
import { useData } from './data'
const isOpen = ref(false)
const isCollapsed = ref(false)
/**
* a11y: cache the element that opened the Sidebar (the menu button) then
@ -65,6 +66,27 @@ export function useSidebarControl() {
}
}
export function useSidebarCollapse() {
function collapse() {
isCollapsed.value = true
}
function expand() {
isCollapsed.value = false
}
function toggleCollapse() {
isCollapsed.value = !isCollapsed.value
}
return {
isCollapsed,
collapse,
expand,
toggleCollapse
}
}
export function useSidebarItemControl(
item: ComputedRef<DefaultTheme.SidebarItem>
) {

@ -76,6 +76,12 @@
.vpi-search {
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.6'%3E%3Cpath d='m21 21l-4.34-4.34'/%3E%3Ccircle cx='11' cy='11' r='8' stroke-width='1.4'/%3E%3C/g%3E%3C/svg%3E");
}
.vpi-sidebar-expand {
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect x='3' y='4' width='6' height='16' rx='1' fill='black'/%3E%3Crect x='11' y='4' width='10' height='16' rx='1' stroke='black' stroke-width='1.5' fill='none'/%3E%3C/svg%3E");
}
.vpi-sidebar-collapse {
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect x='3' y='4' width='6' height='16' rx='1' stroke='black' stroke-width='1.5' fill='none'/%3E%3Crect x='11' y='4' width='10' height='16' rx='1' stroke='black' stroke-width='1.5' fill='none'/%3E%3C/svg%3E");
}
.vpi-layout-list {
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Crect width='7' height='7' x='3' y='3' rx='1'/%3E%3Crect width='7' height='7' x='3' y='14' rx='1'/%3E%3Cpath d='M14 4h7m-7 5h7m-7 6h7m-7 5h7'/%3E%3C/g%3E%3C/svg%3E");
}

Loading…
Cancel
Save