feat(theme): enable multi level sidebar nesting (#1360) (#1835)

close #1360
close #1361
close #1680
pull/1568/merge
Kia King Ishii 1 year ago committed by GitHub
parent 8782c82fee
commit c35a1f0fae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -4,7 +4,9 @@ describe('test multi sidebar sort root', () => {
})
test('using / sidebar', async () => {
const sidebarLocator = page.locator('.VPSidebarGroup .title-text')
const sidebarLocator = page.locator(
'.VPSidebarItem.level-0 > .item > .link > .text'
)
const sidebarContent = await sidebarLocator.allTextContents()
expect(sidebarContent).toEqual([
@ -22,7 +24,9 @@ describe('test multi sidebar sort order', () => {
})
test('using /multi-sidebar/ sidebar', async () => {
const sidebarLocator = page.locator('.VPSidebarGroup .title-text')
const sidebarLocator = page.locator(
'.VPSidebarItem.level-0 > .item > .link > .text'
)
const sidebarContent = await sidebarLocator.allTextContents()
expect(sidebarContent).toEqual(['Multi Sidebar'])

@ -1,96 +1,131 @@
import { getSidebar } from 'client/theme-default/support/sidebar'
import { getSidebar, hasActiveLink } from 'client/theme-default/support/sidebar'
describe('client/theme-default/support/sidebar', () => {
const root = [
{
text: 'A',
collapsible: true,
items: [
{
text: 'A',
link: ''
}
]
},
{
text: 'B',
items: [
{
text: 'B',
link: ''
}
]
}
]
const another = [
{
text: 'C',
items: [
describe('getSidebar', () => {
const root = [
{
text: 'A',
collapsible: true,
items: [{ text: 'A', link: '' }]
},
{
text: 'B',
items: [{ text: 'B', link: '' }]
}
]
const another = [
{
text: 'C',
items: [{ text: 'C', link: '' }]
}
]
describe('normal sidebar sort', () => {
const normalSidebar = {
'/': root,
'/multi-sidebar/': another
}
test('gets `/` sidebar', () => {
expect(getSidebar(normalSidebar, '/')).toBe(root)
})
test('gets `/multi-sidebar/` sidebar', () => {
expect(getSidebar(normalSidebar, '/multi-sidebar/')).toBe(another)
})
test('gets `/` sidebar again', () => {
expect(getSidebar(normalSidebar, '/some-entry.html')).toBe(root)
})
})
describe('reversed sidebar sort', () => {
const reversedSidebar = {
'/multi-sidebar/': another,
'/': root
}
test('gets `/` sidebar', () => {
expect(getSidebar(reversedSidebar, '/')).toBe(root)
})
test('gets `/multi-sidebar/` sidebar', () => {
expect(getSidebar(reversedSidebar, '/multi-sidebar/')).toBe(another)
})
test('gets `/` sidebar again', () => {
expect(getSidebar(reversedSidebar, '/some-entry.html')).toBe(root)
})
})
describe('nested sidebar sort', () => {
const nested = [
{
text: 'C',
link: ''
text: 'D',
items: [{ text: 'D', link: '' }]
}
]
}
]
describe('normal sidebar sort', () => {
const normalSidebar = {
'/': root,
'/multi-sidebar/': another
}
test('gets / sidebar', () => {
expect(getSidebar(normalSidebar, '/')).toBe(root)
})
test('gets /multi-sidebar/ sidebar', () => {
expect(getSidebar(normalSidebar, '/multi-sidebar/')).toBe(another)
})
test('gets / sidebar again', () => {
expect(getSidebar(normalSidebar, '/some-entry.html')).toBe(root)
})
})
describe('reversed sidebar sort', () => {
const reversedSidebar = {
'/multi-sidebar/': another,
'/': root
}
test('gets / sidebar', () => {
expect(getSidebar(reversedSidebar, '/')).toBe(root)
})
test('gets /multi-sidebar/ sidebar', () => {
expect(getSidebar(reversedSidebar, '/multi-sidebar/')).toBe(another)
})
test('gets / sidebar again', () => {
expect(getSidebar(reversedSidebar, '/some-entry.html')).toBe(root)
const nestedSidebar = {
'/': root,
'/multi-sidebar/': another,
'/multi-sidebar/nested/': nested
}
test('gets `/` sidebar', () => {
expect(getSidebar(nestedSidebar, '/')).toBe(root)
})
test('gets `/multi-sidebar/` sidebar', () => {
expect(getSidebar(nestedSidebar, '/multi-sidebar/')).toBe(another)
})
test('gets `/multi-sidebar/nested/` sidebar', () => {
expect(getSidebar(nestedSidebar, '/multi-sidebar/nested/')).toBe(nested)
})
test('gets `/` sidebar again', () => {
expect(getSidebar(nestedSidebar, '/some-entry.html')).toBe(root)
})
})
})
describe('nested sidebar sort', () => {
const nested = [
{
text: 'D',
describe('hasActiveLink', () => {
test('checks `SidebarItem`', () => {
const item = {
text: 'Item 001',
items: [
{
text: 'D',
link: ''
}
{ text: 'Item 001', link: '/active-1' },
{ text: 'Item 002', link: '/active-2' }
]
}
]
const nestedSidebar = {
'/': root,
'/multi-sidebar/': another,
'/multi-sidebar/nested/': nested
}
test('gets / sidebar', () => {
expect(getSidebar(nestedSidebar, '/')).toBe(root)
})
test('gets /multi-sidebar/ sidebar', () => {
expect(getSidebar(nestedSidebar, '/multi-sidebar/')).toBe(another)
})
test('gets /multi-sidebar/nested/ sidebar', () => {
expect(getSidebar(nestedSidebar, '/multi-sidebar/nested/')).toBe(nested)
expect(hasActiveLink('active-1', item)).toBe(true)
expect(hasActiveLink('inactive', item)).toBe(false)
})
test('gets / sidebar again', () => {
expect(getSidebar(nestedSidebar, '/some-entry.html')).toBe(root)
test('checks `SidebarItem[]`', () => {
const item = [
{
text: 'Item 001',
items: [
{ text: 'Item 001', link: '/active-1' },
{ text: 'Item 002', link: '/active-2' }
]
},
{
text: 'Item 002',
items: [
{ text: 'Item 003', link: '/active-3' },
{ text: 'Item 004', link: '/active-4' }
]
}
]
expect(hasActiveLink('active-1', item)).toBe(true)
expect(hasActiveLink('active-3', item)).toBe(true)
expect(hasActiveLink('inactive', item)).toBe(false)
})
})
})

@ -124,22 +124,41 @@ export default {
```
```ts
type Sidebar = SidebarGroup[] | SidebarMulti
export type Sidebar = SidebarItem[] | SidebarMulti
interface SidebarMulti {
[path: string]: SidebarGroup[]
export interface SidebarMulti {
[path: string]: SidebarItem[]
}
interface SidebarGroup {
text: string
items: SidebarItem[]
export type SidebarItem = {
/**
* The text label of the item.
*/
text?: string
/**
* The link of the item.
*/
link?: string
/**
* The children of the item.
*/
items?: SidebarItem[]
/**
* If `true`, toggle button is shown.
*
* @default false
*/
collapsible?: boolean
collapsed?: boolean
}
interface SidebarItem {
text: string
link: string
/**
* If `true`, collapsible group is collapsed by default.
*
* @default false
*/
collapsed?: boolean
}
```

@ -1,6 +1,6 @@
# Sidebar
The sidebar is the main navigation block for your documentation. You can configure the sidebar menu in `themeConfig.sidebar`.
The sidebar is the main navigation block for your documentation. You can configure the sidebar menu in [`themeConfig.sidebar`](/config/theme-configs#sidebar).
```js
export default {
@ -21,7 +21,7 @@ export default {
## The Basics
The simplest form of the sidebar menu is passing in a single array of links. The first level item defines the "section" for the sidebar. It should contain `text`, which is the title of the section, and `items` which are the actual navigation links.
The simplest form of the sidebar menu is passing in a single array of links. The first level item defines the "section" for the sidebar. It should contain `text`, which is the title of the section, and `items` which are the actual navigation links.
```js
export default {
@ -66,6 +66,33 @@ export default {
}
```
You may further nest the sidebar items up to 6 level deep counting up from the root level. Note that deeper than 6 level of nested items gets ignored and will not be displayed on the sidebar.
```js
export default {
themeConfig: {
sidebar: [
{
text: 'Level 1',
items: [
{
text: 'Level 2',
items: [
{
text: 'Level 3',
items: [
...
]
}
]
}
]
}
]
}
}
```
## Multiple Sidebars
You may show different sidebar depending on the page path. For example, as shown on this site, you might want to create a separate sections of content in your documentation like "Guide" page and "Config" page.
@ -90,30 +117,28 @@ Then, update your configuration to define your sidebar for each section. This ti
export default {
themeConfig: {
sidebar: {
// This sidebar gets displayed when user is
// under `guide` directory.
// This sidebar gets displayed when a user
// is on `guide` directory.
'/guide/': [
{
text: 'Guide',
items: [
// This shows `/guide/index.md` page.
{ text: 'Index', link: '/guide/' }, // /guide/index.md
{ text: 'One', link: '/guide/one' }, // /guide/one.md
{ text: 'Two', link: '/guide/two' } // /guide/two.md
{ text: 'Index', link: '/guide/' },
{ text: 'One', link: '/guide/one' },
{ text: 'Two', link: '/guide/two' }
]
}
],
// This sidebar gets displayed when user is
// under `config` directory.
// This sidebar gets displayed when a user
// is on `config` directory.
'/config/': [
{
text: 'Config',
items: [
// This shows `/config/index.md` page.
{ text: 'Index', link: '/config/' }, // /config/index.md
{ text: 'Three', link: '/config/three' }, // /config/three.md
{ text: 'Four', link: '/config/four' } // /config/four.md
{ text: 'Index', link: '/config/' },
{ text: 'Three', link: '/config/three' },
{ text: 'Four', link: '/config/four' }
]
}
]

@ -5,16 +5,18 @@ import VPIconExternalLink from './icons/VPIconExternalLink.vue'
import { EXTERNAL_URL_RE } from '../../shared.js'
const props = defineProps<{
tag?: string
href?: string
noIcon?: boolean
}>()
const tag = computed(() => props.tag ?? props.href ? 'a' : 'span')
const isExternal = computed(() => props.href && EXTERNAL_URL_RE.test(props.href))
</script>
<template>
<component
:is="href ? 'a' : 'span'"
:is="tag"
class="VPLink"
:class="{ link: href }"
:href="href ? normalizeLink(href) : undefined"

@ -2,9 +2,9 @@
import { ref, watchPostEffect } from 'vue'
import { disableBodyScroll, clearAllBodyScrollLocks } from 'body-scroll-lock'
import { useSidebar } from '../composables/sidebar.js'
import VPSidebarGroup from './VPSidebarGroup.vue'
import VPSidebarItem from './VPSidebarItem.vue'
const { sidebar, hasSidebar } = useSidebar()
const { sidebarGroups, hasSidebar } = useSidebar()
const props = defineProps<{
open: boolean
@ -48,13 +48,8 @@ watchPostEffect(async () => {
<slot name="sidebar-nav-before" />
<div v-for="group in sidebar" :key="group.text" class="group">
<VPSidebarGroup
:text="group.text"
:items="group.items"
:collapsible="group.collapsible"
:collapsed="group.collapsed"
/>
<div v-for="item in sidebarGroups" :key="item.text" class="group">
<VPSidebarItem :item="item" :depth="0" />
</div>
<slot name="sidebar-nav-after" />
@ -134,7 +129,6 @@ watchPostEffect(async () => {
}
.group + .group {
margin-top: 32px;
border-top: 1px solid var(--vp-c-divider);
padding-top: 10px;
}
@ -144,9 +138,5 @@ watchPostEffect(async () => {
padding-top: 10px;
width: calc(var(--vp-sidebar-width) - 64px);
}
.group + .group {
margin-top: 24px;
}
}
</style>

@ -1,128 +0,0 @@
<script lang="ts" setup>
import type { DefaultTheme } from 'vitepress/theme'
import { ref, watchEffect } from 'vue'
import { useData } from '../composables/data.js'
import { isActive } from '../support/utils.js'
import VPIconPlusSquare from './icons/VPIconPlusSquare.vue'
import VPIconMinusSquare from './icons/VPIconMinusSquare.vue'
import VPSidebarLink from './VPSidebarLink.vue'
const props = defineProps<{
text?: string
items: DefaultTheme.SidebarItem[]
collapsible?: boolean
collapsed?: boolean
}>()
const collapsed = ref(false)
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
}
}
</script>
<template>
<section class="VPSidebarGroup" :class="{ collapsible, collapsed }">
<div
v-if="text"
class="title"
:role="collapsible ? 'button' : undefined"
@click="toggle"
>
<h2 v-html="text" class="title-text"></h2>
<div class="action">
<VPIconMinusSquare class="icon minus" />
<VPIconPlusSquare class="icon plus" />
</div>
</div>
<div class="items">
<template v-for="item in items" :key="item.link">
<VPSidebarLink :item="item" />
</template>
</div>
</section>
</template>
<style scoped>
.title {
display: flex;
justify-content: space-between;
align-items: flex-start;
z-index: 2;
}
.title-text {
padding-top: 6px;
padding-bottom: 6px;
line-height: 20px;
font-size: 14px;
font-weight: 700;
color: var(--vp-c-text-1);
}
.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;
}
.VPSidebarGroup.collapsible .action {
display: block;
}
.VPSidebarGroup.collapsible .title {
cursor: pointer;
}
.title:hover .action {
color: var(--vp-c-text-2);
}
.icon {
position: absolute;
top: 8px;
left: 8px;
width: 16px;
height: 16px;
fill: currentColor;
}
.icon.minus { opacity: 1; }
.icon.plus { opacity: 0; }
.VPSidebarGroup.collapsed .icon.minus { opacity: 0; }
.VPSidebarGroup.collapsed .icon.plus { opacity: 1; }
.items {
overflow: hidden;
}
.VPSidebarGroup.collapsed .items {
margin-bottom: -22px;
max-height: 0;
}
@media (min-width: 960px) {
.VPSidebarGroup.collapsed .items {
margin-bottom: -14px;
}
}
</style>

@ -0,0 +1,213 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { DefaultTheme } from 'vitepress/theme'
import { useSidebarControl } from '../composables/sidebar.js'
import VPIconChevronRight from './icons/VPIconChevronRight.vue'
import VPLink from './VPLink.vue'
const props = defineProps<{
item: DefaultTheme.SidebarItem
depth: number
}>()
const {
collapsed,
collapsible,
isLink,
isActiveLink,
hasActiveLink,
hasChildren,
toggle
} = useSidebarControl(computed(() => props.item))
const sectionTag = computed(() => hasChildren.value ? 'section' : `div`)
const linkTag = computed(() => isLink.value ? 'a' : 'div')
const textTag = computed(() => {
return !hasChildren.value
? 'p'
: props.depth + 2 === 7 ? 'p' : `h${props.depth + 2}`
})
const itemRole = computed(() => isLink.value ? undefined : 'button')
const classes = computed(() => [
[`level-${props.depth}`],
{ collapsible: collapsible.value },
{ collapsed: collapsed.value },
{ 'is-link': isLink.value },
{ 'is-active': isActiveLink.value },
{ 'has-active': hasActiveLink.value }
])
function onItemClick() {
!props.item.link && toggle()
}
function onCaretClick() {
props.item.link && toggle()
}
</script>
<template>
<component :is="sectionTag" class="VPSidebarItem" :class="classes">
<div v-if="item.text" class="item" :role="itemRole" @click="onItemClick">
<div class="indicator" />
<VPLink :tag="linkTag" class="link" :href="item.link">
<component :is="textTag" class="text" v-html="item.text" />
</VPLink>
<div class="caret" role="button" @click="onCaretClick">
<VPIconChevronRight v-if="item.collapsible" class="caret-icon" />
</div>
</div>
<div v-if="item.items && item.items.length" class="items">
<template v-if="depth < 5">
<VPSidebarItem
v-for="i in item.items"
:key="i.text"
:item="i"
:depth="depth + 1"
/>
</template>
</div>
</component>
</template>
<style scoped>
.VPSidebarItem.level-0 {
padding-bottom: 24px;
}
.VPSidebarItem.collapsed.level-0 {
padding-bottom: 10px;
}
.item {
position: relative;
display: flex;
width: 100%;
}
.VPSidebarItem.collapsible > .item {
cursor: pointer;
}
.indicator {
position: absolute;
top: 6px;
bottom: 6px;
left: -17px;
width: 1px;
transition: background-color 0.25s;
}
.VPSidebarItem.level-2.is-active > .item > .indicator,
.VPSidebarItem.level-3.is-active > .item > .indicator,
.VPSidebarItem.level-4.is-active > .item > .indicator,
.VPSidebarItem.level-5.is-active > .item > .indicator {
background-color: var(--vp-c-brand);
}
.link {
display: block;
flex-grow: 1;
}
.text {
flex-grow: 1;
padding: 4px 0;
line-height: 24px;
font-size: 14px;
transition: color 0.25s;
}
.VPSidebarItem.level-0 .text {
font-weight: 700;
color: var(--vp-c-text-1);
}
.VPSidebarItem.level-1 .text,
.VPSidebarItem.level-2 .text,
.VPSidebarItem.level-3 .text,
.VPSidebarItem.level-4 .text,
.VPSidebarItem.level-5 .text {
font-weight: 500;
color: var(--vp-c-text-2);
}
.VPSidebarItem.level-0.is-link > .item > .link:hover .text,
.VPSidebarItem.level-1.is-link > .item > .link:hover .text,
.VPSidebarItem.level-2.is-link > .item > .link:hover .text,
.VPSidebarItem.level-3.is-link > .item > .link:hover .text,
.VPSidebarItem.level-4.is-link > .item > .link:hover .text,
.VPSidebarItem.level-5.is-link > .item > .link:hover .text {
color: var(--vp-c-brand);
}
.VPSidebarItem.level-0.has-active > .item > .link > .text,
.VPSidebarItem.level-1.has-active > .item > .link > .text,
.VPSidebarItem.level-2.has-active > .item > .link > .text,
.VPSidebarItem.level-3.has-active > .item > .link > .text,
.VPSidebarItem.level-4.has-active > .item > .link > .text,
.VPSidebarItem.level-5.has-active > .item > .link > .text {
color: var(--vp-c-text-1);
}
.VPSidebarItem.level-0.is-active > .item .link > .text,
.VPSidebarItem.level-1.is-active > .item .link > .text,
.VPSidebarItem.level-2.is-active > .item .link > .text,
.VPSidebarItem.level-3.is-active > .item .link > .text,
.VPSidebarItem.level-4.is-active > .item .link > .text,
.VPSidebarItem.level-5.is-active > .item .link > .text {
color: var(--vp-c-brand);
}
.caret {
display: flex;
justify-content: center;
align-items: center;
margin-right: -7px;
width: 32px;
height: 32px;
color: var(--vp-c-text-3);
cursor: pointer;
transition: color 0.25s;
}
.item:hover .caret {
color: var(--vp-c-text-2);
}
.item:hover .caret:hover {
color: var(--vp-c-text-1);
}
.caret-icon {
width: 18px;
height: 18px;
fill: currentColor;
transform: rotate(90deg);
transition: transform 0.25s;
}
.VPSidebarItem.collapsed .caret-icon {
transform: rotate(0);
}
.VPSidebarItem.level-1 .items,
.VPSidebarItem.level-2 .items,
.VPSidebarItem.level-3 .items,
.VPSidebarItem.level-4 .items,
.VPSidebarItem.level-5 .items {
border-left: 1px solid var(--vp-c-divider);
padding-left: 16px;
}
.VPSidebarItem.collapsed .items {
display: none;
}
</style>

@ -1,88 +0,0 @@
<script lang="ts" setup>
import type { DefaultTheme } from 'vitepress/theme'
import { type Ref, computed, inject, ref, watchEffect } from 'vue'
import { useData } from '../composables/data.js'
import { useSidebar } from '../composables/sidebar.js'
import { isActive } from '../support/utils.js'
import VPLink from './VPLink.vue'
const props = withDefaults(
defineProps<{ item: DefaultTheme.SidebarItem; depth?: number }>(),
{ depth: 1 }
)
const { page, frontmatter } = useData()
const maxDepth = computed<number>(
() => frontmatter.value.sidebarDepth || Infinity
)
const active = computed(() =>
isActive(page.value.relativePath, props.item.link)
)
const { isSidebarEnabled } = useSidebar()
const closeSideBar = inject('close-sidebar') as () => void
const isSidebarOpen = inject('is-sidebar-open') as Ref<boolean>
const link = ref<InstanceType<typeof VPLink> | null>(null)
watchEffect(() => {
if (isSidebarOpen.value && active.value) {
link.value?.$el?.focus()
}
})
</script>
<template>
<VPLink
class="link"
:class="{ active }"
:style="{ paddingLeft: 16 * (depth - 1) + 'px' }"
:href="item.link"
:tabindex="isSidebarEnabled || isSidebarOpen ? 0 : -1"
@click="closeSideBar"
ref="link"
>
<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>
</template>
<style scoped>
.link {
display: block;
margin: 4px 0;
color: var(--vp-c-text-2);
transition: color 0.5s;
}
.link:hover {
color: var(--vp-c-text-1);
}
.link.active {
color: var(--vp-c-brand);
}
.link :deep(.icon) {
width: 12px;
height: 12px;
fill: currentColor;
}
.link-text {
line-height: 20px;
font-size: 14px;
font-weight: 500;
}
.link-text.light {
font-size: 13px;
font-weight: 400;
}
</style>

@ -1,15 +1,32 @@
import {
type ComputedRef,
type Ref,
computed,
onMounted,
onUnmounted,
type Ref,
ref,
watchEffect
} from 'vue'
import { useRoute } from 'vitepress'
import { useMediaQuery } from '@vueuse/core'
import { useRoute } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme'
import { isActive } from '../support/utils.js'
import {
hasActiveLink as containsActiveLink,
getSidebar,
getSidebarGroups
} from '../support/sidebar.js'
import { useData } from './data.js'
import { getSidebar } from '../support/sidebar.js'
export interface SidebarControl {
collapsed: Ref<boolean>
collapsible: ComputedRef<boolean>
isLink: ComputedRef<boolean>
isActiveLink: ComputedRef<boolean>
hasActiveLink: ComputedRef<boolean>
hasChildren: ComputedRef<boolean>
toggle(): void
}
export function useSidebar() {
const route = useRoute()
@ -40,6 +57,10 @@ export function useSidebar() {
const isSidebarEnabled = computed(() => hasSidebar.value && is960.value)
const sidebarGroups = computed(() => {
return hasSidebar.value ? getSidebarGroups(sidebar.value) : []
})
function open() {
isOpen.value = true
}
@ -55,6 +76,7 @@ export function useSidebar() {
return {
isOpen,
sidebar,
sidebarGroups,
hasSidebar,
hasAside,
isSidebarEnabled,
@ -95,3 +117,61 @@ export function useCloseSidebarOnEscape(
}
}
}
export function useSidebarControl(
item: ComputedRef<DefaultTheme.SidebarItem>
): SidebarControl {
const { page } = useData()
const collapsed = ref(false)
const collapsible = computed(() => {
return !!item.value.collapsible
})
const isLink = computed(() => {
return !!item.value.link
})
const isActiveLink = computed(() => {
return isActive(page.value.relativePath, item.value.link)
})
const hasActiveLink = computed(() => {
if (isActiveLink.value) {
return true
}
return item.value.items
? containsActiveLink(page.value.relativePath, item.value.items)
: false
})
const hasChildren = computed(() => {
return !!(item.value.items && item.value.items.length)
})
watchEffect(() => {
collapsed.value = !!(item.value.collapsible && item.value.collapsed)
})
watchEffect(() => {
;(isActiveLink.value || hasActiveLink.value) && (collapsed.value = false)
})
function toggle() {
if (item.value.collapsible) {
collapsed.value = !collapsed.value
}
}
return {
collapsed,
collapsible,
isLink,
isActiveLink,
hasActiveLink,
hasChildren,
toggle
}
}

@ -1,5 +1,10 @@
import type { DefaultTheme } from 'vitepress/theme'
import { ensureStartingSlash } from './utils.js'
import { ensureStartingSlash, isActive } from './utils.js'
export interface SidebarLink {
text: string
link: string
}
/**
* Get the `Sidebar` from sidebar option. This method will ensure to get correct
@ -10,7 +15,7 @@ import { ensureStartingSlash } from './utils.js'
export function getSidebar(
sidebar: DefaultTheme.Sidebar | undefined,
path: string
): DefaultTheme.SidebarGroup[] {
): DefaultTheme.SidebarItem[] {
if (Array.isArray(sidebar)) {
return sidebar
}
@ -33,22 +38,70 @@ export function getSidebar(
return dir ? sidebar[dir] : []
}
export function getFlatSideBarLinks(sidebar: DefaultTheme.SidebarGroup[]) {
const links: { text: string; link: string }[] = []
/**
* Get or generate sidebar group from the given sidebar items.
*/
export function getSidebarGroups(
sidebar: DefaultTheme.SidebarItem[]
): DefaultTheme.SidebarItem[] {
const groups: DefaultTheme.SidebarItem[] = []
let lastGroupIndex: number = 0
for (const index in sidebar) {
const item = sidebar[index]
if (item.items) {
lastGroupIndex = groups.push(item)
continue
}
if (!groups[lastGroupIndex]) {
groups.push({ items: [] })
}
groups[lastGroupIndex]!.items!.push(item)
}
return groups
}
export function getFlatSideBarLinks(
sidebar: DefaultTheme.SidebarItem[]
): SidebarLink[] {
const links: SidebarLink[] = []
function recursivelyExtractLinks(items: DefaultTheme.SidebarItem[]) {
for (const item of items) {
if (item.link) {
links.push({ ...item, link: item.link })
if (item.text && item.link) {
links.push({ text: item.text, link: item.link })
}
if ('items' in item) {
if (item.items) {
recursivelyExtractLinks(item.items)
}
}
}
for (const group of sidebar) {
recursivelyExtractLinks(group.items)
}
recursivelyExtractLinks(sidebar)
return links
}
/**
* Check if the given sidebar item contains any active link.
*/
export function hasActiveLink(
path: string,
items: DefaultTheme.SidebarItem | DefaultTheme.SidebarItem[]
): boolean {
if (Array.isArray(items)) {
return items.some((item) => hasActiveLink(path, item))
}
return isActive(path, items.link)
? true
: items.items
? hasActiveLink(path, items.items)
: false
}

@ -160,15 +160,27 @@ export namespace DefaultTheme {
// sidebar -------------------------------------------------------------------
export type Sidebar = SidebarGroup[] | SidebarMulti
export type Sidebar = SidebarItem[] | SidebarMulti
export interface SidebarMulti {
[path: string]: SidebarGroup[]
[path: string]: SidebarItem[]
}
export interface SidebarGroup {
export type SidebarItem = {
/**
* The text label of the item.
*/
text?: string
items: SidebarItem[]
/**
* The link of the item.
*/
link?: string
/**
* The children of the item.
*/
items?: SidebarItem[]
/**
* If `true`, toggle button is shown.
@ -185,10 +197,6 @@ export namespace DefaultTheme {
collapsed?: boolean
}
export type SidebarItem =
| { text: string; link: string }
| { text: string; link?: string; items: SidebarItem[] }
// edit link -----------------------------------------------------------------
export interface EditLink {

Loading…
Cancel
Save