feat: allow using components in navigation bar (#4000)

---------

Co-authored-by: Divyansh Singh <40380293+brc-dd@users.noreply.github.com>
pull/4028/head
Joaquín Sánchez 4 months ago committed by GitHub
parent fa81e89643
commit fa87d8150d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,5 +1,50 @@
import { defineConfig, type DefaultTheme } from 'vitepress'
const nav: DefaultTheme.Config['nav'] = [
{
text: 'Home',
link: '/'
},
{
text: 'API Reference',
items: [
{
text: 'Example',
link: '/home.html'
},
{
component: 'ApiPreference',
props: {
options: ['JavaScript', 'TypeScript', 'Flow'],
defaultOption: 'TypeScript'
}
},
{
component: 'ApiPreference',
props: {
options: ['Options', 'Composition'],
defaultOption: 'Composition'
}
}
]
},
{
component: 'NavVersion',
props: {
versions: [
{
text: 'v1.x',
link: '/'
},
{
text: 'v0.x',
link: '/v0.x/'
}
]
}
}
]
const sidebar: DefaultTheme.Config['sidebar'] = {
'/': [
{
@ -92,6 +137,7 @@ export default defineConfig({
}
},
themeConfig: {
nav,
sidebar,
search: {
provider: 'local',

@ -0,0 +1,83 @@
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
const props = defineProps<{
options: string[]
defaultOption: string
screenMenu?: boolean
}>()
// reactivity isn't needed for props here
const key = removeSpaces(`api-preference-${props.options.join('-')}`)
const name = key + (props.screenMenu ? '-screen-menu' : '')
const selected = useLocalStorage(key, () => props.defaultOption)
const optionsWithKeys = props.options.map((option) => ({
key: name + '-' + removeSpaces(option),
value: option
}))
function removeSpaces(str: string) {
return str.replace(/\s/g, '_')
}
</script>
<template>
<div class="VPApiPreference" :class="{ 'screen-menu': screenMenu }">
<template v-for="option in optionsWithKeys" :key="option">
<input
type="radio"
:id="option.key"
:name="name"
:value="option.value"
v-model="selected"
/>
<label :for="option.key">{{ option.value }}</label>
</template>
</div>
</template>
<style scoped>
.VPApiPreference {
display: flex;
margin: 12px 0;
border: 1px solid var(--vp-c-border);
border-radius: 6px;
font-size: 14px;
color: var(--vp-c-text-1);
}
.VPApiPreference:first-child {
margin-top: 0;
}
.VPApiPreference:last-child {
margin-bottom: 0;
}
.VPApiPreference.screen-menu {
margin: 12px 0 0 12px;
}
.VPApiPreference input[type='radio'] {
pointer-events: none;
position: fixed;
opacity: 0;
}
.VPApiPreference label {
flex: 1;
margin: 2px;
padding: 4px 12px;
cursor: pointer;
border-radius: 4px;
text-align: center;
}
.VPApiPreference input[type='radio']:checked + label {
background-color: var(--vp-c-default-soft);
color: var(--vp-c-brand-1);
}
</style>

@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vitepress'
import VPNavBarMenuGroup from 'vitepress/dist/client/theme-default/components/VPNavBarMenuGroup.vue'
import VPNavScreenMenuGroup from 'vitepress/dist/client/theme-default/components/VPNavScreenMenuGroup.vue'
const props = defineProps<{
versions: { text: string; link: string }[]
screenMenu?: boolean
}>()
const route = useRoute()
const sortedVersions = computed(() => {
return [...props.versions].sort(
(a, b) => b.link.split('/').length - a.link.split('/').length
)
})
const currentVersion = computed(() => {
return (
sortedVersions.value.find((version) => route.path.startsWith(version.link))
?.text || 'Versions'
)
})
</script>
<template>
<VPNavBarMenuGroup
v-if="!screenMenu"
:item="{ text: currentVersion, items: versions }"
class="VPNavVersion"
/>
<VPNavScreenMenuGroup
v-else
:text="currentVersion"
:items="versions"
class="VPNavVersion"
/>
</template>
<style scoped>
.VPNavVersion :deep(button .text) {
color: var(--vp-c-text-1) !important;
}
.VPNavVersion:hover :deep(button .text) {
color: var(--vp-c-text-2) !important;
}
</style>

@ -0,0 +1,12 @@
import type { Theme } from 'vitepress'
import DefaultTheme from 'vitepress/theme'
import ApiPreference from './components/ApiPreference.vue'
import NavVersion from './components/NavVersion.vue'
export default {
extends: DefaultTheme,
enhanceApp({ app }) {
app.component('ApiPreference', ApiPreference)
app.component('NavVersion', NavVersion)
}
} satisfies Theme

@ -160,3 +160,57 @@ export default {
## Social Links
Refer [`socialLinks`](./default-theme-config#sociallinks).
## Custom Components
You can include custom components in the navigation bar by using the `component` option. The `component` key should be the Vue component name, and must be registered globally using [Theme.enhanceApp](../guide/custom-theme#theme-interface).
```js
// .vitepress/config.js
export default {
themeConfig: {
nav: [
{
text: 'My Menu',
items: [
{
component: 'MyCustomComponent',
// Optional props to pass to the component
props: {
title: 'My Custom Component'
}
}
]
},
{
component: 'AnotherCustomComponent'
}
]
}
}
```
Then, you need to register the component globally:
```js
// .vitepress/theme/index.js
import DefaultTheme from 'vitepress/theme'
import MyCustomComponent from './components/MyCustomComponent.vue'
import AnotherCustomComponent from './components/AnotherCustomComponent.vue'
/** @type {import('vitepress').Theme} */
export default {
extends: DefaultTheme,
enhanceApp({ app }) {
app.component('MyCustomComponent', MyCustomComponent)
app.component('AnotherCustomComponent', AnotherCustomComponent)
}
}
```
Your component will be rendered in the navigation bar. VitePress will provide the following additional props to the component:
- `screenMenu`: an optional boolean indicating whether the component is inside mobile navigation menu
You can check an example in the e2e tests [here](https://github.com/vuejs/vitepress/tree/main/__tests__/e2e/.vitepress).

@ -100,20 +100,20 @@
"dependencies": {
"@docsearch/css": "^3.6.0",
"@docsearch/js": "^3.6.0",
"@shikijs/core": "^1.9.0",
"@shikijs/transformers": "^1.9.0",
"@shikijs/core": "^1.10.3",
"@shikijs/transformers": "^1.10.3",
"@types/markdown-it": "^14.1.1",
"@vitejs/plugin-vue": "^5.0.5",
"@vue/devtools-api": "^7.3.4",
"@vue/shared": "^3.4.30",
"@vue/devtools-api": "^7.3.5",
"@vue/shared": "^3.4.31",
"@vueuse/core": "^10.11.0",
"@vueuse/integrations": "^10.11.0",
"focus-trap": "^7.5.4",
"mark.js": "8.11.1",
"minisearch": "^6.3.0",
"shiki": "^1.9.0",
"vite": "^5.3.1",
"vue": "^3.4.30"
"shiki": "^1.10.3",
"vite": "^5.3.3",
"vue": "^3.4.31"
},
"devDependencies": {
"@clack/prompts": "^0.7.0",
@ -138,16 +138,16 @@
"@types/markdown-it-attrs": "^4.1.3",
"@types/markdown-it-container": "^2.0.10",
"@types/markdown-it-emoji": "^3.0.1",
"@types/micromatch": "^4.0.7",
"@types/micromatch": "^4.0.9",
"@types/minimist": "^1.2.5",
"@types/node": "^20.14.8",
"@types/node": "^20.14.10",
"@types/postcss-prefix-selector": "^1.16.3",
"@types/prompts": "^2.4.9",
"chokidar": "^3.6.0",
"conventional-changelog-cli": "^5.0.0",
"cross-spawn": "^7.0.3",
"debug": "^4.3.5",
"esbuild": "^0.21.5",
"esbuild": "^0.23.0",
"execa": "^9.3.0",
"fast-glob": "^3.3.2",
"fs-extra": "^11.2.0",
@ -155,7 +155,7 @@
"gray-matter": "^4.0.3",
"lint-staged": "^15.2.7",
"lodash.template": "^4.5.0",
"lru-cache": "^10.2.2",
"lru-cache": "^10.3.1",
"markdown-it": "^14.1.0",
"markdown-it-anchor": "^9.0.1",
"markdown-it-attrs": "^4.1.6",
@ -170,13 +170,13 @@
"path-to-regexp": "^6.2.2",
"picocolors": "^1.0.1",
"pkg-dir": "^8.0.0",
"playwright-chromium": "^1.44.1",
"playwright-chromium": "^1.45.1",
"polka": "^1.0.0-next.25",
"postcss-prefix-selector": "^1.16.1",
"prettier": "^3.3.2",
"prompts": "^2.4.2",
"punycode": "^2.3.1",
"rimraf": "^5.0.7",
"rimraf": "^5.0.8",
"rollup": "^4.18.0",
"rollup-plugin-dts": "^6.1.1",
"rollup-plugin-esbuild": "^6.1.1",
@ -185,9 +185,9 @@
"sirv": "^2.0.4",
"sitemap": "^8.0.0",
"supports-color": "^9.4.0",
"typescript": "^5.5.2",
"typescript": "^5.5.3",
"vitest": "^1.6.0",
"vue-tsc": "^2.0.22",
"vue-tsc": "^2.0.26",
"wait-on": "^7.2.0"
},
"peerDependencies": {

File diff suppressed because it is too large Load Diff

@ -10,8 +10,13 @@ defineProps<{
<template>
<div class="VPMenu">
<div v-if="items" class="items">
<template v-for="item in items" :key="item.text">
<template v-for="item in items" :key="JSON.stringify(item)">
<VPMenuLink v-if="'link' in item" :item="item" />
<component
v-else-if="'component' in item"
:is="item.component"
v-bind="item.props"
/>
<VPMenuGroup v-else :text="item.text" :items="item.items" />
</template>
</div>
@ -63,7 +68,7 @@ defineProps<{
font-size: 12px;
font-weight: 500;
color: var(--vp-c-text-2);
transition: color .5s;
transition: color 0.5s;
}
.VPMenu :deep(.action) {

@ -7,10 +7,21 @@ const { theme } = useData()
</script>
<template>
<nav v-if="theme.nav" aria-labelledby="main-nav-aria-label" class="VPNavBarMenu">
<span id="main-nav-aria-label" class="visually-hidden">Main Navigation</span>
<template v-for="item in theme.nav" :key="item.text">
<nav
v-if="theme.nav"
aria-labelledby="main-nav-aria-label"
class="VPNavBarMenu"
>
<span id="main-nav-aria-label" class="visually-hidden">
Main Navigation
</span>
<template v-for="item in theme.nav" :key="JSON.stringify(item)">
<VPNavBarMenuLink v-if="'link' in item" :item="item" />
<component
v-else-if="'component' in item"
:is="item.component"
v-bind="item.props"
/>
<VPNavBarMenuGroup v-else :item="item" />
</template>
</nav>

@ -12,15 +12,17 @@ const props = defineProps<{
const { page } = useData()
const isChildActive = (navItem: DefaultTheme.NavItem) => {
if ('component' in navItem) return false
if ('link' in navItem) {
return isActive(
page.value.relativePath,
navItem.link,
!!props.item.activeMatch
)
} else {
return navItem.items.some(isChildActive)
}
return navItem.items.some(isChildActive)
}
const childrenActive = computed(() => isChildActive(props.item))
@ -30,11 +32,9 @@ const childrenActive = computed(() => isChildActive(props.item))
<VPFlyout
:class="{
VPNavBarMenuGroup: true,
active: isActive(
page.relativePath,
item.activeMatch,
!!item.activeMatch
) || childrenActive
active:
isActive(page.relativePath, item.activeMatch, !!item.activeMatch) ||
childrenActive
}"
:button="item.text"
:items="item.items"

@ -8,10 +8,13 @@ const { theme } = useData()
<template>
<nav v-if="theme.nav" class="VPNavScreenMenu">
<template v-for="item in theme.nav" :key="item.text">
<VPNavScreenMenuLink
v-if="'link' in item"
:item="item"
<template v-for="item in theme.nav" :key="JSON.stringify(item)">
<VPNavScreenMenuLink v-if="'link' in item" :item="item" />
<component
v-else-if="'component' in item"
:is="item.component"
v-bind="item.props"
screen-menu
/>
<VPNavScreenMenuGroup
v-else

@ -10,8 +10,8 @@ const props = defineProps<{
const isOpen = ref(false)
const groupId = computed(() =>
`NavScreenGroup-${props.text.replace(' ', '-').toLowerCase()}`
const groupId = computed(
() => `NavScreenGroup-${props.text.replace(' ', '-').toLowerCase()}`
)
function toggle() {
@ -32,16 +32,17 @@ function toggle() {
</button>
<div :id="groupId" class="items">
<template v-for="item in items" :key="item.text">
<div v-if="'link' in item" :key="item.text" class="item">
<template v-for="item in items" :key="JSON.stringify(item)">
<div v-if="'link' in item" class="item">
<VPNavScreenMenuGroupLink :item="item" />
</div>
<div v-else-if="'component' in item" class="item">
<component :is="item.component" v-bind="item.props" screen-menu />
</div>
<div v-else class="group">
<VPNavScreenMenuGroupSection
:text="item.text"
:items="item.items"
/>
<VPNavScreenMenuGroupSection :items="item.items" />
</div>
</template>
</div>

@ -162,7 +162,12 @@ export namespace DefaultTheme {
// nav -----------------------------------------------------------------------
export type NavItem = NavItemWithLink | NavItemWithChildren
export type NavItem = NavItemComponent | NavItemWithLink | NavItemWithChildren
export interface NavItemComponent {
component: string
props?: Record<string, any>
}
export interface NavItemWithLink {
text: string
@ -186,7 +191,7 @@ export namespace DefaultTheme {
export interface NavItemWithChildren {
text?: string
items: (NavItemChildren | NavItemWithLink)[]
items: (NavItemComponent | NavItemChildren | NavItemWithLink)[]
/**
* `activeMatch` is expected to be a regex string. We can't use actual

Loading…
Cancel
Save