Skip to content

feat: add support for collapsible multi-level sidebar #1361

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 9 commits into from
11 changes: 1 addition & 10 deletions src/client/theme-default/components/VPSidebarGroup.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
<script lang="ts" setup>
import type { DefaultTheme } from 'vitepress/theme'
import { ref, watchEffect } from 'vue'
import { useData } from 'vitepress'
import { isActive } from '../support/utils.js'
import VPIconPlusSquare from './icons/VPIconPlusSquare.vue'
import VPIconMinusSquare from './icons/VPIconMinusSquare.vue'
import VPSidebarLink from './VPSidebarLink.vue'
@@ -19,13 +17,6 @@ watchEffect(() => {
collapsed.value = !!(props.collapsible && props.collapsed)
})

const { page } = useData()
watchEffect(() => {
if(props.items.some((item) => { return isActive(page.value.relativePath, item.link) })){
collapsed.value = false
}
})

function toggle() {
if (props.collapsible) {
collapsed.value = !collapsed.value
@@ -50,7 +41,7 @@ function toggle() {

<div class="items">
<template v-for="item in items" :key="item.link">
<VPSidebarLink :item="item" />
<VPSidebarLink :item="item" @active-route="collapsed = false" />
</template>
</div>
</section>
152 changes: 134 additions & 18 deletions src/client/theme-default/components/VPSidebarLink.vue
Original file line number Diff line number Diff line change
@@ -1,43 +1,100 @@
<script lang="ts" setup>
import type { DefaultTheme } from 'vitepress/theme'
import { computed, inject } from 'vue'
import { computed, inject, ref, watch, watchEffect } from 'vue'
import { useData } from 'vitepress'
import { isActive } from '../support/utils.js'
import VPLink from './VPLink.vue'
import VPIconPlusSquare from './icons/VPIconPlusSquare.vue'
import VPIconMinusSquare from './icons/VPIconMinusSquare.vue'

withDefaults(
const props = withDefaults(
defineProps<{ item: DefaultTheme.SidebarItem; depth?: number }>(),
{ depth: 1 }
)
const emits = defineEmits<{
(event: 'active-route'): void
}>()

const { page, frontmatter } = useData()
const maxDepth = computed<number>(
() => frontmatter.value.sidebarDepth || Infinity
)
const closeSideBar = inject('close-sidebar') as () => void

const collapsible = computed(() => 'items' in props.item ? !!props.item.collapsible : false)
const collapsed = ref(false)

/**
* When this node is both a page and a parent node,
* a split line will be added between the collapse button and the link to distinguish the functional area.
*/
const divider = computed(() => !!props.item.link && collapsible.value)


watchEffect(() => {
if ('items' in props.item)
collapsed.value = !!(collapsible.value && props.item.collapsed)
})

function routeActiveBubbling() {
collapsed.value = false
emits('active-route')
}

watch(() => isActive(page.value.relativePath, props.item.link), active => {
if (active) {
routeActiveBubbling()
}
}, { immediate: true })

function toggle() {
if (collapsible.value) {
collapsed.value = !collapsed.value
}
}

function clickLink() {
// If there are no links to jump to, switch to expand when clicking on the text
if (!props.item.link)
toggle()
else
closeSideBar()
}
</script>

<template>
<VPLink
class="link"
:class="{ active: isActive(page.relativePath, item.link) }"
:style="{ paddingLeft: 16 * (depth - 1) + 'px' }"
:href="item.link"
@click="closeSideBar"
>
<span v-html="item.text" class="link-text" :class="{ light: depth > 1 }"></span>
</VPLink>
<template
v-if="'items' in item && depth < maxDepth"
v-for="child in item.items"
:key="child.link"
>
<VPSidebarLink :item="child" :depth="depth + 1" />
</template>
<section class="VPSidebarLink" :class="{ collapsible, collapsed }">
<div class="link-label">
<VPLink
class="link"
:class="{ active: isActive(page.relativePath, item.link), divider }"
:style="{ paddingLeft: 16 * (depth - 1) + 'px' }"
:href="item.link"
@click="clickLink"
>
<span v-html="item.text" class="link-text" :class="{ light: depth > 1 }"></span>
</VPLink>

<button class="action" @click.stop="toggle" type="button">
<VPIconMinusSquare class="icon minus" />
<VPIconPlusSquare class="icon plus" />
</button>
</div>
<div class="items">
<template
v-if="'items' in item && depth < maxDepth"
v-for="child in item.items"
:key="child.link"
>
<VPSidebarLink :item="child" :depth="depth + 1" @active-route="routeActiveBubbling" />
</template>
</div>
</section>
</template>

<style scoped>
.link {
flex: 1;
display: block;
margin: 4px 0;
color: var(--vp-c-text-2);
@@ -68,4 +125,63 @@ const closeSideBar = inject('close-sidebar') as () => void
font-size: 13px;
font-weight: 400;
}

.link-label {
display: flex;
align-items: center;
}

.link.divider {
border-right: 1px solid var(--vp-c-divider-light);
}

.link:not(.divider):hover + .action {
color: var(--vp-c-text-2);
}

.action {
display: none;
position: relative;
margin-right: -8px;
border-radius: 4px;
width: 32px;
height: 32px;
color: var(--vp-c-text-3);
transition: color 0.25s;
}

.action:hover {
color: var(--vp-c-text-2);
}

.VPSidebarLink.collapsible > .link-label .action {
display: block;
}

.VPSidebarLink.collapsible > .link-label .link {
cursor: pointer;
}

.icon {
position: absolute;
top: 8px;
left: 8px;
width: 16px;
height: 16px;
fill: currentColor;
}

.icon.minus { opacity: 1; }
.icon.plus { opacity: 0; }

.VPSidebarLink.collapsed > .link-label .icon.minus { opacity: 0; }
.VPSidebarLink.collapsed > .link-label .icon.plus { opacity: 1; }

.items {
overflow: hidden;
}

.VPSidebarLink.collapsed .items {
max-height: 0;
}
</style>
20 changes: 19 additions & 1 deletion types/default-theme.d.ts
Original file line number Diff line number Diff line change
@@ -150,7 +150,25 @@ export namespace DefaultTheme {

export type SidebarItem =
| { text: string; link: string }
| { text: string; link?: string; items: SidebarItem[] }
| {
text: string
link?: string
items: SidebarItem[]

/**
* If `true`, toggle button is shown.
*
* @default false
*/
collapsible?: boolean

/**
* If `true`, collapsible group is collapsed by default.
*
* @default false
*/
collapsed?: boolean
}

// edit link -----------------------------------------------------------------