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 () => { 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() const sidebarContent = await sidebarLocator.allTextContents()
expect(sidebarContent).toEqual([ expect(sidebarContent).toEqual([
@ -22,7 +24,9 @@ describe('test multi sidebar sort order', () => {
}) })
test('using /multi-sidebar/ sidebar', async () => { 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() const sidebarContent = await sidebarLocator.allTextContents()
expect(sidebarContent).toEqual(['Multi Sidebar']) 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', () => { describe('client/theme-default/support/sidebar', () => {
const root = [ describe('getSidebar', () => {
{ const root = [
text: 'A', {
collapsible: true, text: 'A',
items: [ collapsible: true,
{ items: [{ text: 'A', link: '' }]
text: 'A', },
link: '' {
} text: 'B',
] items: [{ text: 'B', link: '' }]
}, }
{ ]
text: 'B',
items: [ const another = [
{ {
text: 'B', text: 'C',
link: '' items: [{ text: 'C', link: '' }]
} }
] ]
}
] describe('normal sidebar sort', () => {
const another = [ const normalSidebar = {
{ '/': root,
text: 'C', '/multi-sidebar/': another
items: [ }
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', text: 'D',
link: '' items: [{ text: 'D', link: '' }]
} }
] ]
}
] const nestedSidebar = {
describe('normal sidebar sort', () => { '/': root,
const normalSidebar = { '/multi-sidebar/': another,
'/': root, '/multi-sidebar/nested/': nested
'/multi-sidebar/': another }
}
test('gets / sidebar', () => { test('gets `/` sidebar', () => {
expect(getSidebar(normalSidebar, '/')).toBe(root) expect(getSidebar(nestedSidebar, '/')).toBe(root)
}) })
test('gets /multi-sidebar/ sidebar', () => {
expect(getSidebar(normalSidebar, '/multi-sidebar/')).toBe(another) test('gets `/multi-sidebar/` sidebar', () => {
}) expect(getSidebar(nestedSidebar, '/multi-sidebar/')).toBe(another)
test('gets / sidebar again', () => { })
expect(getSidebar(normalSidebar, '/some-entry.html')).toBe(root)
}) test('gets `/multi-sidebar/nested/` sidebar', () => {
}) expect(getSidebar(nestedSidebar, '/multi-sidebar/nested/')).toBe(nested)
describe('reversed sidebar sort', () => { })
const reversedSidebar = {
'/multi-sidebar/': another, test('gets `/` sidebar again', () => {
'/': root expect(getSidebar(nestedSidebar, '/some-entry.html')).toBe(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 = [ describe('hasActiveLink', () => {
{ test('checks `SidebarItem`', () => {
text: 'D', const item = {
text: 'Item 001',
items: [ items: [
{ { text: 'Item 001', link: '/active-1' },
text: 'D', { text: 'Item 002', link: '/active-2' }
link: ''
}
] ]
} }
]
const nestedSidebar = { expect(hasActiveLink('active-1', item)).toBe(true)
'/': root, expect(hasActiveLink('inactive', item)).toBe(false)
'/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) 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 ```ts
type Sidebar = SidebarGroup[] | SidebarMulti export type Sidebar = SidebarItem[] | SidebarMulti
interface SidebarMulti { export interface SidebarMulti {
[path: string]: SidebarGroup[] [path: string]: SidebarItem[]
} }
interface SidebarGroup { export type SidebarItem = {
text: string /**
items: 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 collapsible?: boolean
collapsed?: boolean
}
interface SidebarItem { /**
text: string * If `true`, collapsible group is collapsed by default.
link: string *
* @default false
*/
collapsed?: boolean
} }
``` ```

@ -1,6 +1,6 @@
# Sidebar # 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 ```js
export default { export default {
@ -21,7 +21,7 @@ export default {
## The Basics ## 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 ```js
export default { 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 ## 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. 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 { export default {
themeConfig: { themeConfig: {
sidebar: { sidebar: {
// This sidebar gets displayed when user is // This sidebar gets displayed when a user
// under `guide` directory. // is on `guide` directory.
'/guide/': [ '/guide/': [
{ {
text: 'Guide', text: 'Guide',
items: [ items: [
// This shows `/guide/index.md` page. { text: 'Index', link: '/guide/' },
{ text: 'Index', link: '/guide/' }, // /guide/index.md { text: 'One', link: '/guide/one' },
{ text: 'One', link: '/guide/one' }, // /guide/one.md { text: 'Two', link: '/guide/two' }
{ text: 'Two', link: '/guide/two' } // /guide/two.md
] ]
} }
], ],
// This sidebar gets displayed when user is // This sidebar gets displayed when a user
// under `config` directory. // is on `config` directory.
'/config/': [ '/config/': [
{ {
text: 'Config', text: 'Config',
items: [ items: [
// This shows `/config/index.md` page. { text: 'Index', link: '/config/' },
{ text: 'Index', link: '/config/' }, // /config/index.md { text: 'Three', link: '/config/three' },
{ text: 'Three', link: '/config/three' }, // /config/three.md { text: 'Four', link: '/config/four' }
{ text: 'Four', link: '/config/four' } // /config/four.md
] ]
} }
] ]

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

@ -2,9 +2,9 @@
import { ref, watchPostEffect } from 'vue' import { ref, watchPostEffect } from 'vue'
import { disableBodyScroll, clearAllBodyScrollLocks } from 'body-scroll-lock' import { disableBodyScroll, clearAllBodyScrollLocks } from 'body-scroll-lock'
import { useSidebar } from '../composables/sidebar.js' 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<{ const props = defineProps<{
open: boolean open: boolean
@ -48,13 +48,8 @@ watchPostEffect(async () => {
<slot name="sidebar-nav-before" /> <slot name="sidebar-nav-before" />
<div v-for="group in sidebar" :key="group.text" class="group"> <div v-for="item in sidebarGroups" :key="item.text" class="group">
<VPSidebarGroup <VPSidebarItem :item="item" :depth="0" />
:text="group.text"
:items="group.items"
:collapsible="group.collapsible"
:collapsed="group.collapsed"
/>
</div> </div>
<slot name="sidebar-nav-after" /> <slot name="sidebar-nav-after" />
@ -134,7 +129,6 @@ watchPostEffect(async () => {
} }
.group + .group { .group + .group {
margin-top: 32px;
border-top: 1px solid var(--vp-c-divider); border-top: 1px solid var(--vp-c-divider);
padding-top: 10px; padding-top: 10px;
} }
@ -144,9 +138,5 @@ watchPostEffect(async () => {
padding-top: 10px; padding-top: 10px;
width: calc(var(--vp-sidebar-width) - 64px); width: calc(var(--vp-sidebar-width) - 64px);
} }
.group + .group {
margin-top: 24px;
}
} }
</style> </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 { import {
type ComputedRef,
type Ref,
computed, computed,
onMounted, onMounted,
onUnmounted, onUnmounted,
type Ref,
ref, ref,
watchEffect watchEffect
} from 'vue' } from 'vue'
import { useRoute } from 'vitepress'
import { useMediaQuery } from '@vueuse/core' 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 { 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() { export function useSidebar() {
const route = useRoute() const route = useRoute()
@ -40,6 +57,10 @@ export function useSidebar() {
const isSidebarEnabled = computed(() => hasSidebar.value && is960.value) const isSidebarEnabled = computed(() => hasSidebar.value && is960.value)
const sidebarGroups = computed(() => {
return hasSidebar.value ? getSidebarGroups(sidebar.value) : []
})
function open() { function open() {
isOpen.value = true isOpen.value = true
} }
@ -55,6 +76,7 @@ export function useSidebar() {
return { return {
isOpen, isOpen,
sidebar, sidebar,
sidebarGroups,
hasSidebar, hasSidebar,
hasAside, hasAside,
isSidebarEnabled, 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 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 * 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( export function getSidebar(
sidebar: DefaultTheme.Sidebar | undefined, sidebar: DefaultTheme.Sidebar | undefined,
path: string path: string
): DefaultTheme.SidebarGroup[] { ): DefaultTheme.SidebarItem[] {
if (Array.isArray(sidebar)) { if (Array.isArray(sidebar)) {
return sidebar return sidebar
} }
@ -33,22 +38,70 @@ export function getSidebar(
return dir ? sidebar[dir] : [] 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[]) { function recursivelyExtractLinks(items: DefaultTheme.SidebarItem[]) {
for (const item of items) { for (const item of items) {
if (item.link) { if (item.text && item.link) {
links.push({ ...item, link: item.link }) links.push({ text: item.text, link: item.link })
} }
if ('items' in item) {
if (item.items) {
recursivelyExtractLinks(item.items) recursivelyExtractLinks(item.items)
} }
} }
} }
for (const group of sidebar) { recursivelyExtractLinks(sidebar)
recursivelyExtractLinks(group.items)
}
return links 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 ------------------------------------------------------------------- // sidebar -------------------------------------------------------------------
export type Sidebar = SidebarGroup[] | SidebarMulti export type Sidebar = SidebarItem[] | SidebarMulti
export interface SidebarMulti { export interface SidebarMulti {
[path: string]: SidebarGroup[] [path: string]: SidebarItem[]
} }
export interface SidebarGroup { export type SidebarItem = {
/**
* The text label of the item.
*/
text?: string text?: string
items: SidebarItem[]
/**
* The link of the item.
*/
link?: string
/**
* The children of the item.
*/
items?: SidebarItem[]
/** /**
* If `true`, toggle button is shown. * If `true`, toggle button is shown.
@ -185,10 +197,6 @@ export namespace DefaultTheme {
collapsed?: boolean collapsed?: boolean
} }
export type SidebarItem =
| { text: string; link: string }
| { text: string; link?: string; items: SidebarItem[] }
// edit link ----------------------------------------------------------------- // edit link -----------------------------------------------------------------
export interface EditLink { export interface EditLink {

Loading…
Cancel
Save