feat(theme): redesign the navbar (#5397)

Co-authored-by: bluwy <34116392+bluwy@users.noreply.github.com>
Co-authored-by: xbsheng <56357338+xbsheng@users.noreply.github.com>
Co-authored-by: fvsch <243601+fvsch@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
pull/5400/head
Divyansh Singh 3 weeks ago committed by GitHub
parent 0f0fe13576
commit 26b76d6d9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -5,12 +5,13 @@ const props = defineProps<{
options: string[]
defaultOption: string
screenMenu?: boolean
menu?: boolean
}>()
// reactivity isn't needed for props here
const key = removeSpaces(`api-preference-${props.options.join('-')}`)
const name = key + (props.screenMenu ? '-screen-menu' : '')
const name = key + (props.screenMenu ? '-screen-menu' : props.menu ? '-menu' : '')
const selected = useLocalStorage(key, () => props.defaultOption)
@ -25,8 +26,11 @@ function removeSpaces(str: string) {
</script>
<template>
<div class="VPApiPreference" :class="{ 'screen-menu': screenMenu }">
<template v-for="option in optionsWithKeys" :key="option">
<div
class="VPApiPreference"
:class="{ 'screen-menu': screenMenu, 'in-menu': menu }"
>
<template v-for="option in optionsWithKeys" :key="option.key">
<input
type="radio"
:id="option.key"
@ -42,10 +46,10 @@ function removeSpaces(str: string) {
<style scoped>
.VPApiPreference {
display: flex;
margin: 12px 0;
margin: 0.75rem 0;
border: 1px solid var(--vp-c-border);
border-radius: 6px;
font-size: 14px;
border-radius: 0.375rem;
font-size: 0.875rem;
color: var(--vp-c-text-1);
}
@ -57,8 +61,13 @@ function removeSpaces(str: string) {
margin-bottom: 0;
}
.VPApiPreference.in-menu {
margin: 0.5rem 0.75rem;
}
.VPApiPreference.screen-menu {
margin: 12px 0 0 12px;
margin: 0.75rem 0 0 0.75rem;
font-size: 1rem;
}
.VPApiPreference input[type='radio'] {
@ -69,10 +78,10 @@ function removeSpaces(str: string) {
.VPApiPreference label {
flex: 1;
margin: 2px;
padding: 4px 12px;
margin: 0.125rem;
padding: 0.25rem 0.75rem;
cursor: pointer;
border-radius: 4px;
border-radius: 0.25rem;
text-align: center;
}

@ -1,12 +1,12 @@
<script setup lang="ts">
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'
import VPNavMenuGroup from 'vitepress/dist/client/theme-default/components/VPNavMenuGroup.vue'
import { computed } from 'vue'
const props = defineProps<{
versions: { text: string; link: string }[]
screenMenu?: boolean
menu?: boolean
}>()
const route = useRoute()
@ -26,15 +26,10 @@ const currentVersion = computed(() => {
</script>
<template>
<VPNavBarMenuGroup
v-if="!screenMenu"
<VPNavMenuGroup
:item="{ text: currentVersion, items: versions }"
class="VPNavVersion"
/>
<VPNavScreenMenuGroup
v-else
:text="currentVersion"
:items="versions"
:screen="screenMenu"
:menu="menu"
class="VPNavVersion"
/>
</template>

@ -67,6 +67,157 @@ describe('navigation accessibility', () => {
expect(await ariaCurrent(sectionTwo)).toBeNull()
})
test('nav landmarks and controls have accessible names', async () => {
await goto('/')
expect(await page.locator('.VPNavBarMenu').getAttribute('aria-label')).toBe(
'Main Navigation'
)
await page.setViewportSize({ width: 375, height: 667 })
expect(
await page.locator('.VPNavBarHamburger').getAttribute('aria-label')
).toBe('Menu')
})
test('dropdown group is a keyboard-dismissible disclosure', async () => {
await goto('/')
const button = page.locator('.VPNavBarMenuGroup').first().locator('button')
expect(await button.getAttribute('aria-expanded')).toBe('false')
await button.click()
expect(await button.getAttribute('aria-expanded')).toBe('true')
await page.keyboard.press('Escape')
expect(await button.getAttribute('aria-expanded')).toBe('false')
// focus returned to the trigger
expect(
await page.evaluate(() => document.activeElement?.textContent)
).toContain('API Reference')
})
test('dropdown closes after navigating via a menu item', async () => {
await goto('/')
const group = page.locator('.VPNavBarMenuGroup').first()
await group.locator('button').click()
await group.locator('a[href="/home.html"]').click()
await page.waitForFunction(() => location.pathname.endsWith('/home.html'))
// the route watcher closes it on the post-navigation tick
await page.waitForSelector(
'.VPNavBarMenuGroup button[aria-expanded="false"]'
)
})
test('overflowing nav items move into the extra menu instead of clipping', async () => {
await goto('/')
// everything fits at 1280, so the ⋯ menu isn't rendered at all
expect(await page.locator('.VPNavBarExtra').count()).toBe(0)
// inflate the items so none of them can possibly fit
const style = await page.addStyleTag({
content: '.VPNavBarMenu .list > li > * { padding: 0 500px !important }'
})
await page.waitForSelector('.VPNavBarExtra')
// every control that stays in the bar remains fully within the viewport
// (no clipped/unreachable items — the failure mode of #2842)
expect(
await page.evaluate(() => {
const targets = [
...document.querySelectorAll('.VPNavBarMenu .list > li'),
document.querySelector('.VPNavBarSearch button'),
document.querySelector('.VPNavBarExtra > button')
].filter((el): el is HTMLElement => !!el)
return targets.every((el) => {
const rect = el.getBoundingClientRect()
return rect.left >= -1 && rect.right <= innerWidth + 1
})
})
).toBe(true)
// collapsed items and social links are reachable through the ⋯ menu
// (the scoped selector targets the moved "Home" item itself — the
// version-switcher component in the menu also links to "/")
await page.locator('.VPNavBarExtra > button').click()
await page.waitForSelector(
'.VPNavBarExtra .overflow-items > .VPMenuLink a[href="/"]'
)
await page.waitForSelector('.VPNavBarExtra .social-links')
// component items render menu-native: a titled group whose links are
// visible in place, not the screen accordion or a nested flyout
await page.waitForSelector(
'.VPNavBarExtra .overflow-items > .VPNavVersion .title'
)
expect(
await page.locator('.VPNavBarExtra .VPNavScreenMenuGroup').count()
).toBe(0)
// widening back restores the inline items and removes the ⋯ menu
await style.evaluate((el) => (el as HTMLStyleElement).remove())
await page.waitForSelector('.VPNavBarExtra', { state: 'detached' })
await page.waitForSelector('.VPNavBarMenuLink[href="/"]')
})
test('nav screen manages focus and inert state', async () => {
await page.setViewportSize({ width: 375, height: 667 })
await goto('/')
const hamburger = page.locator('.VPNavBarHamburger')
expect(await hamburger.getAttribute('aria-expanded')).toBe('false')
await hamburger.click()
await page.waitForSelector('#VPNavScreen')
expect(await hamburger.getAttribute('aria-expanded')).toBe('true')
// the covered page content is inert while the screen is open
expect(
await page.evaluate(() =>
document.getElementById('VPContent')!.hasAttribute('inert')
)
).toBe(true)
await page.keyboard.press('Escape')
await page.waitForSelector('#VPNavScreen', { state: 'detached' })
expect(
await page.evaluate(() =>
document.getElementById('VPContent')!.hasAttribute('inert')
)
).toBe(false)
// focus returned to the hamburger
expect(
await page.evaluate(() => document.activeElement?.className)
).toContain('VPNavBarHamburger')
})
test('screen menu group is a real disclosure', async () => {
await page.setViewportSize({ width: 375, height: 667 })
await goto('/')
await page.locator('.VPNavBarHamburger').click()
const group = page.locator('.VPNavScreenMenuGroup').first()
const button = group.locator('button').first()
expect(await button.getAttribute('aria-expanded')).toBe('false')
// collapsed content is hidden from view and the tab order
expect(await group.locator('a[href="/home.html"]').isVisible()).toBe(false)
await button.click()
expect(await button.getAttribute('aria-expanded')).toBe('true')
await page.waitForSelector('.VPNavScreenMenuGroup a[href="/home.html"]')
// aria-controls points at the panel it toggles
const controls = await button.getAttribute('aria-controls')
expect(await group.locator(`ul[id="${controls}"]`).count()).toBe(1)
})
test.runIf(process.env.VITE_TEST_BUILD)(
'omits aria-current for fragment links in server-rendered HTML',
async () => {

@ -0,0 +1,135 @@
import {
computeNavFit,
type NavFitInput
} from 'client/theme-default/composables/nav-overflow'
function fit(input: Partial<NavFitInput>) {
return computeNavFit({
itemWidths: [],
translations: null,
appearance: null,
socialLinks: null,
available: 0,
extraWidth: 40,
...input
})
}
describe('client/theme-default/composables/nav-overflow', () => {
describe('computeNavFit', () => {
test('keeps everything when it fits', () => {
expect(
fit({
itemWidths: [100, 100],
translations: 50,
appearance: 60,
socialLinks: 90,
available: 400
})
).toEqual({
visibleItemCount: Infinity,
translations: true,
appearance: true,
socialLinks: true
})
})
test('collapses social links first', () => {
expect(
fit({
itemWidths: [100, 100],
translations: 50,
appearance: 60,
socialLinks: 90,
available: 390
})
).toEqual({
visibleItemCount: Infinity,
translations: true,
appearance: true,
socialLinks: false
})
})
test('collapses the cluster cascade in order', () => {
// items (200) + translations (50) fit in the 260 budget after
// reserving the extra button, appearance (60) does not — social links
// must follow appearance out even though they'd fit alone
expect(
fit({
itemWidths: [100, 100],
translations: 50,
appearance: 60,
socialLinks: 5,
available: 300
})
).toEqual({
visibleItemCount: Infinity,
translations: true,
appearance: false,
socialLinks: false
})
})
test('collapses menu items right-to-left after the cluster', () => {
expect(
fit({
itemWidths: [100, 100, 100],
translations: 50,
available: 250
})
).toEqual({
visibleItemCount: 2,
translations: false,
appearance: true,
socialLinks: true
})
})
test('ignores unconfigured cluster units', () => {
expect(
fit({
itemWidths: [100],
socialLinks: 90,
available: 150
})
).toEqual({
visibleItemCount: Infinity,
translations: true,
appearance: true,
socialLinks: false
})
})
test('collapses everything when nothing fits', () => {
expect(
fit({
itemWidths: [100, 100],
translations: 50,
available: 30
})
).toEqual({
visibleItemCount: 0,
translations: false,
// unconfigured units just stay "not collapsed"
appearance: true,
socialLinks: true
})
})
test('a lone overwide item still collapses instead of clipping', () => {
expect(fit({ itemWidths: [500], available: 400 }).visibleItemCount).toBe(
0
)
})
test('handles an empty nav', () => {
expect(fit({ socialLinks: 90, appearance: 60, available: 80 })).toEqual({
visibleItemCount: Infinity,
translations: true,
appearance: false,
socialLinks: false
})
})
})
})

@ -40,6 +40,46 @@ export default DefaultTheme
See [default theme CSS variables](https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css) that can be overridden.
### Navbar
The navbar draws a single background surface controlled by CSS variables, so its look can be changed without touching component internals:
```css
:root {
/* bar height and background */
--vp-nav-height: 4rem;
--vp-nav-bg-color: var(--vp-c-bg);
/* background while on top of the home page (unscrolled);
set to var(--vp-nav-bg-color) to opt out of the transparent treatment */
--vp-nav-home-bg-color: transparent;
/* filter applied to the content behind the bar */
--vp-nav-backdrop-filter: none;
/* the bar's bottom rule and the mobile menu background */
--vp-nav-divider-color: var(--vp-c-gutter);
--vp-nav-screen-bg-color: var(--vp-c-bg);
}
```
For example, a frosted-glass navbar:
```css
:root {
--vp-nav-bg-color: color-mix(in srgb, var(--vp-c-bg) 65%, transparent);
--vp-nav-backdrop-filter: saturate(180%) blur(8px);
}
```
The same treatment carries over to the local nav: `--vp-local-nav-bg-color` follows the navbar surface color by default, and where the two bars meet they share a single blurred surface, so the glass stays continuous across them.
::: warning
`backdrop-filter` has a measurable scroll performance cost, especially on large or high-DPI screens. When using a translucent bar, also check text contrast over your page content.
:::
When the nav items don't fit the available width, they move into the `⋯` menu at the end of the navbar instead of being clipped, starting with the social links, the appearance switch and the locale switcher, followed by the nav items right-to-left. Its button label can be localized with [`extraMenuLabel`](../reference/default-theme-config#extramenulabel).
## Using Different Fonts
VitePress uses [Inter](https://rsms.me/inter/) as the default font, and will include the fonts in the build output. The font is also auto preloaded in production. However, this may not be desirable if you want to use a different main font.

@ -470,6 +470,27 @@ Can be used to customize the label of the return to top button. This label is on
Can be used to customize the aria-label of the language toggle button in navbar. This is only used if you're using [i18n](../guide/i18n).
## navMenuLabel
- Type: `string`
- Default: `Main Navigation`
Can be used to customize the accessible label of the main navigation landmarks (the navbar menu and the mobile menu).
## mobileMenuLabel
- Type: `string`
- Default: `Menu`
Can be used to customize the aria-label of the mobile menu (hamburger) button.
## extraMenuLabel
- Type: `string`
- Default: `More options`
Can be used to customize the aria-label of the `⋯` menu button in the navbar. That menu collects the nav items and controls that don't fit in the bar at the current viewport size.
## skipToContentLabel
- Type: `string`

@ -216,5 +216,6 @@ export default {
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
- `menu`: an optional boolean indicating whether the component is inside a dropdown panel — for example, the `⋯` menu that nav items collapse into when they don't fit the bar. In both these contexts, render a flat list instead of a floating flyout, which would end up nested inside the panel
You can check an example in the e2e tests [here](https://github.com/vuejs/vitepress/tree/main/__tests__/e2e/.vitepress).

@ -57,7 +57,7 @@
"build:check": "publint && attw --pack . --profile esm-only",
"typecheck": "tsc -p tsconfig.shared.json && vue-tsc -p tsconfig.client.json && tsc -p tsconfig.node.json",
"test": "pnpm --aggregate-output --reporter=append-only '/^test:(types|unit|e2e|init)$/'",
"test:types": "tsc -p __tests__/unit && tsc -p __tests__/e2e && tsc -p __tests__/init && vue-tsc -p docs",
"test:types": "tsc -p __tests__/unit && vue-tsc -p __tests__/e2e && tsc -p __tests__/init && vue-tsc -p docs",
"test:unit": "vitest run -r __tests__/unit",
"test:unit:watch": "vitest -r __tests__/unit",
"test:e2e": "pnpm test:e2e-dev && pnpm test:e2e-build",

@ -10,6 +10,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 { useNav } from './composables/nav'
import { useSidebarControl } from './composables/sidebar'
const {
@ -18,6 +19,10 @@ const {
close: closeSidebar
} = useSidebarControl()
// everything behind the open nav screen is inert so keyboard and screen
// reader users can't reach the covered page content
const { isScreenOpen } = useNav()
registerWatchers({ closeSidebar })
const { frontmatter, theme } = useData()
@ -38,7 +43,7 @@ provide(layoutInfoInjectionKey, { heroImageSlotExists })
]"
>
<slot name="layout-top" />
<VPSkipLink />
<VPSkipLink :inert="isScreenOpen" />
<VPBackdrop class="backdrop" :show="isSidebarOpen" @click="closeSidebar" />
<VPNav>
<template #nav-bar-title-before><slot name="nav-bar-title-before" /></template>
@ -48,14 +53,14 @@ provide(layoutInfoInjectionKey, { heroImageSlotExists })
<template #nav-screen-content-before><slot name="nav-screen-content-before" /></template>
<template #nav-screen-content-after><slot name="nav-screen-content-after" /></template>
</VPNav>
<VPLocalNav :open="isSidebarOpen" @open-menu="openSidebar" />
<VPLocalNav :open="isSidebarOpen" @open-menu="openSidebar" :inert="isScreenOpen" />
<VPSidebar :open="isSidebarOpen">
<VPSidebar :open="isSidebarOpen" :inert="isScreenOpen">
<template #sidebar-nav-before><slot name="sidebar-nav-before" /></template>
<template #sidebar-nav-after><slot name="sidebar-nav-after" /></template>
</VPSidebar>
<VPContent>
<VPContent :inert="isScreenOpen">
<template #page-top><slot name="page-top" /></template>
<template #page-bottom><slot name="page-bottom" /></template>
@ -85,7 +90,7 @@ provide(layoutInfoInjectionKey, { heroImageSlotExists })
<template #aside-ads-after><slot name="aside-ads-after" /></template>
</VPContent>
<VPFooter />
<VPFooter :inert="isScreenOpen" />
<slot name="layout-bottom" />
</div>
<Content v-else />

@ -1,6 +1,8 @@
<script lang="ts" setup generic="T extends DefaultTheme.NavItem">
import { onKeyStroke, useEventListener } from '@vueuse/core'
import { useRoute } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme'
import { ref, useTemplateRef } from 'vue'
import { ref, useId, useTemplateRef, watch } from 'vue'
import { useFlyout } from '../composables/flyout'
import VPMenu from './VPMenu.vue'
@ -14,39 +16,99 @@ defineProps<{
const open = ref(false)
const el = useTemplateRef('el')
const buttonEl = useTemplateRef('buttonEl')
const menuEl = useTemplateRef('menuEl')
const menuId = useId()
useFlyout({ el, onBlur: close })
const route = useRoute()
watch(() => route.path, close)
// for mouse users the disclosure opens and closes on hover, with the same
// boundary in both directions: the button plus the open panel. The root box
// isn't used it also contains the ::before divider some contexts draw
// before the flyout, which should act as a neutral gap either way. Closing
// needs no delay: leaving for the panel (or back) is detected via
// relatedTarget, and any real exit closes instantly so a panel never
// lingers over a neighboring flyout while sweeping across the bar.
// a hover-open absorbs the click that usually follows it, otherwise mouse
// users would toggle the menu right back off
let openedByHover = false
function onPointerEnter(e: PointerEvent) {
if (e.pointerType !== 'mouse') return
if (!open.value) {
open.value = true
openedByHover = true
}
}
useFlyout({ el, onBlur })
function onPointerLeave(e: PointerEvent) {
if (e.pointerType !== 'mouse') return
const to = e.relatedTarget as Node | null
// still within the button panel region not an exit
if (to && (buttonEl.value?.contains(to) || menuEl.value?.contains(to))) return
close()
}
function onBlur() {
function toggle() {
if (open.value && openedByHover) {
openedByHover = false
return
}
openedByHover = false
open.value = !open.value
}
function close() {
open.value = false
openedByHover = false
}
// content shown on hover must be dismissible without moving the pointer
// (WCAG 1.4.13) Escape closes and, if focus was inside, returns it to the
// trigger
onKeyStroke('Escape', () => {
if (!open.value) return
const restoreFocus = el.value?.contains(document.activeElement)
close()
if (restoreFocus) {
el.value?.querySelector('button')?.focus()
}
})
// a tap on a non-focusable area outside doesn't move focus, so the
// focus-tracking blur alone can't dismiss the menu on touch
useEventListener('pointerdown', (e) => {
if (open.value && el.value && !el.value.contains(e.target as Node)) close()
})
</script>
<template>
<div
class="VPFlyout"
ref="el"
@mouseenter="open = true"
@mouseleave="open = false"
>
<div class="VPFlyout" ref="el">
<button
ref="buttonEl"
type="button"
class="button"
aria-haspopup="true"
:aria-expanded="open"
:aria-controls="menuId"
:aria-label="label"
@click="open = !open"
@pointerenter="onPointerEnter"
@pointerleave="onPointerLeave"
@click="toggle"
>
<span v-if="button || icon" class="text">
<span v-if="icon" :class="[icon, 'option-icon']" />
<span v-if="icon" :class="[icon, 'option-icon']" aria-hidden="true" />
<span v-if="button" v-html="button"></span>
<span class="vpi-chevron-down text-icon" />
<span class="vpi-chevron-down text-icon" aria-hidden="true" />
</span>
<span v-else class="vpi-more-horizontal icon" />
<span v-else class="vpi-more-horizontal icon" aria-hidden="true" />
</button>
<div class="menu">
<div ref="menuEl" class="menu" :id="menuId" @pointerleave="onPointerLeave">
<VPMenu :items>
<slot />
</VPMenu>
@ -80,17 +142,17 @@ function onBlur() {
color: var(--vp-c-brand-2);
}
/* closing is snappier than opening so a panel doesn't linger over the
neighboring flyout's panel while sweeping across the bar */
.button[aria-expanded="false"] + .menu {
opacity: 0;
visibility: hidden;
transform: translateY(0);
transition: opacity 0.1s, visibility 0.1s;
}
.VPFlyout:hover .menu,
.button[aria-expanded="true"] + .menu {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.button {
@ -113,7 +175,6 @@ function onBlur() {
}
.option-icon {
margin-right: 0px;
font-size: 1rem;
}
@ -133,6 +194,6 @@ function onBlur() {
right: 0;
opacity: 0;
visibility: hidden;
transition: opacity 0.25s, visibility 0.25s, transform 0.25s;
transition: opacity 0.25s, visibility 0.25s;
}
</style>

@ -31,30 +31,29 @@ onMounted(() => {
probe.remove()
})
const classes = computed(() => {
return {
VPLocalNav: true,
'has-sidebar': hasSidebar.value,
empty: !hasLocalNav.value,
fixed: !hasLocalNav.value && !hasSidebar.value
}
})
const isScrolled = computed(() => y.value >= navHeight.value)
</script>
<template>
<div
v-if="!isHome && (hasLocalNav || hasSidebar || y >= navHeight)"
:class="classes"
v-if="!isHome && (hasLocalNav || hasSidebar || isScrolled)"
class="VPLocalNav"
:class="{
'has-sidebar': hasSidebar,
'empty': !hasLocalNav,
'fixed': !hasLocalNav && !hasSidebar
}"
>
<div class="container">
<button
v-if="hasSidebar"
type="button"
class="menu"
:aria-expanded="open"
aria-controls="VPSidebarNav"
@click="$emit('open-menu')"
>
<span class="vpi-align-left menu-icon"></span>
<span class="vpi-align-left menu-icon" aria-hidden="true"></span>
<span class="menu-text">
{{ theme.sidebarMenuLabel || 'Menu' }}
</span>
@ -75,7 +74,21 @@ const classes = computed(() => {
border-bottom: 1px solid var(--vp-c-gutter);
padding-top: var(--vp-layout-top-height, 0px);
width: 100%;
}
/* the background surface below 60rem it covers just this bar; from 60rem
the bar is pinned under the fixed navbar, so the surface extends up
behind it and one element carries the backdrop filter for both bars
(two stacked filters would show a seam at their shared edge) */
.VPLocalNav::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background-color: var(--vp-local-nav-bg-color);
backdrop-filter: var(--vp-nav-backdrop-filter);
-webkit-backdrop-filter: var(--vp-nav-backdrop-filter);
transition: background-color 0.25s;
}
.VPLocalNav.fixed {
@ -87,6 +100,10 @@ const classes = computed(() => {
top: var(--vp-nav-height);
}
.VPLocalNav::before {
top: calc(-1 * var(--vp-nav-height));
}
.VPLocalNav.has-sidebar {
padding-left: var(--vp-sidebar-width);
}

@ -2,7 +2,7 @@
import { onKeyStroke } from '@vueuse/core'
import { onContentUpdated } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme'
import { nextTick, ref, useTemplateRef, watch } from 'vue'
import { nextTick, ref, useId, useTemplateRef, watch } from 'vue'
import { useData } from '../composables/data'
import { resolveTitle } from '../composables/outline'
@ -19,6 +19,7 @@ const open = ref(false)
const vh = ref(0)
const main = useTemplateRef('main')
const items = useTemplateRef('items')
const itemsId = useId()
// lock body scroll while the dropdown is open to prevent scroll chaining
const isLocked = useBodyScrollLock()
@ -76,15 +77,22 @@ function scrollToTop() {
:style="{ '--vp-vh': vh + 'px' }"
data-allow-mismatch="style"
>
<button @click="toggle" :class="{ open }" v-if="headers.length > 0">
<button
v-if="headers.length > 0"
type="button"
:aria-expanded="open"
:aria-controls="itemsId"
:class="{ open }"
@click="toggle"
>
<span class="menu-text">{{ resolveTitle(theme) }}</span>
<span class="vpi-chevron-right icon" />
<span class="vpi-chevron-right icon" aria-hidden="true" />
</button>
<button @click="scrollToTop" v-else>
<button v-else type="button" @click="scrollToTop">
{{ theme.returnToTopLabel || 'Return to top' }}
</button>
<Transition name="flyout">
<div v-if="open" ref="items" class="items" @click="onItemClick">
<div v-if="open" ref="items" :id="itemsId" class="items" @click="onItemClick">
<div class="header">
<a class="top-link" href="#" @click="scrollToTop">
{{ theme.returnToTopLabel || 'Return to top' }}

@ -18,6 +18,7 @@ defineProps<{
v-else-if="'component' in item"
:is="item.component"
v-bind="item.props"
menu
/>
<VPMenuGroup v-else :text="item.text" :items="item.items" />
</template>
@ -64,16 +65,4 @@ defineProps<{
white-space: nowrap;
}
.VPMenu :deep(.label) {
flex-grow: 1;
line-height: 2.3333333;
font-size: 0.75rem;
font-weight: 500;
color: var(--vp-c-text-2);
transition: color 0.5s;
}
.VPMenu :deep(.action) {
padding-left: 1.5rem;
}
</style>

@ -9,22 +9,41 @@
"
>
import type { DefaultTheme } from 'vitepress/theme'
import { computed, inject } from 'vue'
import { navScreenInjectionKey } from '../composables/nav'
import VPMenuLink from './VPMenuLink.vue'
defineProps<{
const props = defineProps<{
text?: string
items: T[]
}>()
const screen = inject(navScreenInjectionKey, false)
const hasSubGroups = computed(() =>
props.items.some((item) => !('link' in item) && !('component' in item))
)
</script>
<template>
<li class="VPMenuGroup">
<li
class="VPMenuGroup"
:class="{ VPNavScreenMenuGroupSection: screen }"
>
<p v-if="text" class="title">{{ text }}</p>
<ul>
<ul :class="{ 'sub-groups': hasSubGroups }">
<template v-for="item in items" :key="JSON.stringify(item)">
<VPMenuLink v-if="'link' in item" :item />
<component
v-else-if="'component' in item"
:is="item.component"
v-bind="item.props"
:screen-menu="screen || undefined"
:menu="!screen || undefined"
/>
<VPMenuGroup v-else :text="item.text" :items="item.items" />
</template>
</ul>
</li>
@ -57,4 +76,35 @@ defineProps<{
white-space: nowrap;
transition: color 0.25s;
}
.VPNavScreen .VPMenuGroup {
margin: 0;
border: none;
padding: 0;
}
.VPNavScreen .title {
padding: 0;
line-height: 2.4615385;
font-size: 0.8125rem;
font-weight: 700;
white-space: normal;
}
.VPMenuGroup > .sub-groups {
margin: 0.25rem 0 0.25rem 0.75rem;
border-left: 1px solid var(--vp-c-divider);
padding-left: 0.25rem;
}
.VPMenuGroup .VPMenuGroup,
.VPMenuGroup .VPMenuGroup + .VPMenuGroup {
margin: 0;
border-top: 0;
padding: 0.5rem 0 0;
}
.VPMenuGroup .VPMenuGroup:first-child {
padding-top: 0;
}
</style>

@ -1,7 +1,12 @@
<script lang="ts" setup generic="T extends DefaultTheme.NavItemWithLink">
import type { DefaultTheme } from 'vitepress/theme'
import { inject } from 'vue'
import { useNavItemLink } from '../composables/nav'
import {
navInjectionKey,
navScreenInjectionKey,
useNavItemLink
} from '../composables/nav'
import VPLink from './VPLink.vue'
const props = defineProps<{
@ -11,6 +16,13 @@ const props = defineProps<{
const { href, isActiveLink, isCurrentLink } = useNavItemLink(() => props.item)
const screen = inject(navScreenInjectionKey, false)
const nav = inject(navInjectionKey, null)
function onClick() {
if (screen) nav?.closeScreen()
}
defineOptions({ inheritAttrs: false })
</script>
@ -18,12 +30,16 @@ defineOptions({ inheritAttrs: false })
<li class="VPMenuLink">
<VPLink
v-bind="$attrs"
:class="{ active: isActiveLink }"
:class="{
active: isActiveLink,
VPNavScreenMenuGroupLink: screen
}"
:aria-current="isCurrentLink ? 'page' : undefined"
:href
:target="item.target"
:rel="props.rel ?? item.rel"
:no-icon="item.noIcon"
@click="onClick"
>
<span v-html="item.text"></span>
</VPLink>
@ -37,6 +53,12 @@ defineOptions({ inheritAttrs: false })
padding: 0.75rem 0.75rem 0;
}
.VPMenuGroup .VPMenuGroup + .VPMenuLink {
margin: 0;
border-top: 0;
padding: 0.5rem 0 0;
}
.link {
display: block;
border-radius: 0.375rem;
@ -58,4 +80,23 @@ defineOptions({ inheritAttrs: false })
.link.active {
color: var(--vp-c-brand-1);
}
.VPNavScreen .VPMenuLink {
margin: 0;
border: none;
padding: 0;
}
.VPNavScreen .link {
display: block;
margin-left: 0.75rem;
border-radius: 0;
padding: 0;
font-weight: 400;
white-space: normal;
}
.VPNavScreen .link:hover {
background-color: transparent;
}
</style>

@ -47,7 +47,6 @@ watchEffect(() => {
z-index: var(--vp-z-index-nav);
width: 100%;
pointer-events: none;
transition: background-color 0.5s;
}
@media (min-width: 60rem) {

@ -0,0 +1,92 @@
<script lang="ts" setup>
import { computed, useId } from 'vue'
import { useData } from '../composables/data'
import { useAppearanceSwitch } from '../composables/nav'
import { useNavOverflow } from '../composables/nav-overflow'
import VPSwitchAppearance from './VPSwitchAppearance.vue'
const props = defineProps<{
/** labeled row (nav screen and `⋯` menu) instead of the bare switch */
row?: boolean
/** styling context for the row variant */
screen?: boolean
}>()
const { theme } = useData()
const show = useAppearanceSwitch()
// only the inline bar switch participates in the overflow engine
const overflow = props.row ? null : useNavOverflow()
const isCollapsed = computed(() => !!overflow && !overflow.state.appearance)
const labelId = useId()
</script>
<template>
<div
v-if="show"
class="VPNavAppearance"
:class="[
row ? (screen ? 'VPNavScreenAppearance' : 'menu-appearance') : 'VPNavBarAppearance',
{ collapsed: isCollapsed }
]"
:ref="(el) => overflow?.setClusterEl('appearance', el as HTMLElement | null)"
>
<p v-if="row" :id="labelId" class="text">
{{ theme.darkModeSwitchLabel || 'Appearance' }}
</p>
<VPSwitchAppearance :aria-labelledby="row ? labelId : undefined" />
</div>
</template>
<style scoped>
.VPNavBarAppearance {
display: none;
}
@media (min-width: 48rem) {
.VPNavBarAppearance {
display: flex;
align-items: center;
}
}
.VPNavAppearance .text {
font-size: 0.75rem;
font-weight: 500;
color: var(--vp-c-text-2);
}
/* labeled row inside the nav screen */
.VPNavScreenAppearance {
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 0.5rem;
padding: 0.75rem 0.875rem 0.75rem 1rem;
background-color: var(--vp-c-bg-soft);
}
.VPNavScreenAppearance .text {
line-height: 2;
}
/* labeled row inside the `⋯` menu */
.menu-appearance {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
min-width: 11rem;
padding: 0 0.75rem;
}
/* matches the menu group titles */
.menu-appearance .text {
line-height: 2.2857143;
font-size: 0.875rem;
font-weight: 600;
}
</style>

@ -1,17 +1,20 @@
<script lang="ts" setup>
import { useWindowScroll } from '@vueuse/core'
import { computed } from 'vue'
import { useData } from '../composables/data'
import { useLayout } from '../composables/layout'
import VPNavBarAppearance from './VPNavBarAppearance.vue'
import { provideNavOverflow } from '../composables/nav-overflow'
import VPNavAppearance from './VPNavAppearance.vue'
import VPNavBarExtra from './VPNavBarExtra.vue'
import VPNavBarHamburger from './VPNavBarHamburger.vue'
import VPNavBarMenu from './VPNavBarMenu.vue'
import VPNavBarSearch from './VPNavBarSearch.vue'
import VPNavBarSocialLinks from './VPNavBarSocialLinks.vue'
import VPNavBarTitle from './VPNavBarTitle.vue'
import VPNavBarTranslations from './VPNavBarTranslations.vue'
import VPNavMenu from './VPNavMenu.vue'
import VPNavSocialLinks from './VPNavSocialLinks.vue'
import VPNavTranslations from './VPNavTranslations.vue'
const props = defineProps<{
defineProps<{
isScreenOpen: boolean
}>()
@ -19,8 +22,15 @@ defineEmits<{
(e: 'toggle-screen'): void
}>()
const { theme } = useData()
const { isHome, hasSidebar, hasLocalNav } = useLayout()
const { y } = useWindowScroll()
const { isHome, hasSidebar } = useLayout()
const isTop = computed(() => y.value <= 0)
const overflow = provideNavOverflow({
itemsKey: () => JSON.stringify(theme.value.nav ?? null)
})
</script>
<template>
@ -28,8 +38,9 @@ const { isHome, hasSidebar } = useLayout()
class="VPNavBar"
:class="{
'has-sidebar': hasSidebar,
'has-local-nav': !isHome && hasLocalNav,
'home': isHome,
'top': y <= 0,
'top': isTop,
'screen-open': isScreenOpen
}"
>
@ -43,13 +54,16 @@ const { isHome, hasSidebar } = useLayout()
</div>
<div class="content">
<div class="content-body">
<div
class="content-body"
:ref="(el) => overflow.setContainerEl(el as HTMLElement | null)"
>
<slot name="nav-bar-content-before" />
<VPNavBarSearch class="search" />
<VPNavBarMenu class="menu" />
<VPNavBarTranslations class="translations" />
<VPNavBarAppearance class="appearance" />
<VPNavBarSocialLinks class="social-links" />
<VPNavMenu class="menu" />
<VPNavTranslations class="translations" />
<VPNavAppearance class="appearance" />
<VPNavSocialLinks class="social-links" />
<VPNavBarExtra class="extra" />
<slot name="nav-bar-content-after" />
<VPNavBarHamburger
@ -75,25 +89,68 @@ const { isHome, hasSidebar } = useLayout()
height: var(--vp-nav-height);
pointer-events: none;
white-space: nowrap;
transition: background-color 0.25s;
/* left edge of the background surface and divider on doc pages the
sidebar column paints its own surface up to this offset */
--vp-nav-col-offset: 0px;
}
.VPNavBar.screen-open {
transition: none;
/* the single background surface every state change below is color-only,
so nothing ever moves */
.VPNavBar::before {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: var(--vp-nav-col-offset);
z-index: -1;
background-color: var(--vp-nav-bg-color);
backdrop-filter: var(--vp-nav-backdrop-filter);
-webkit-backdrop-filter: var(--vp-nav-backdrop-filter);
transition: background-color 0.25s;
}
.VPNavBar:not(.home) {
background-color: var(--vp-nav-bg-color);
/* below 60rem the bar scrolls with the page, so home stays transparent */
.VPNavBar.home::before {
background-color: transparent;
}
@media (min-width: 60rem) {
.VPNavBar:not(.home) {
background-color: transparent;
.VPNavBar.home::before {
background-color: var(--vp-nav-bg-color);
}
.VPNavBar:not(.has-sidebar):not(.home.top) {
background-color: var(--vp-nav-bg-color);
.VPNavBar.home.top::before {
background-color: var(--vp-nav-home-bg-color);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.VPNavBar.has-sidebar {
--vp-nav-col-offset: var(--vp-sidebar-width);
}
}
@media (min-width: 90rem) {
.VPNavBar.has-sidebar {
--vp-nav-col-offset: calc(
(100% - var(--vp-layout-max-width)) / 2 + var(--vp-sidebar-width)
);
}
}
.VPNavBar.screen-open::before {
transition: none;
background-color: var(--vp-nav-bg-color);
}
/* between 60rem and 80rem the local nav is pinned right under the bar and
its surface extends up behind it, carrying the paint for both bars */
@media (60rem <= width < 80rem) {
.VPNavBar.has-local-nav::before {
background-color: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
}
@ -107,12 +164,6 @@ const { isHome, hasSidebar } = useLayout()
}
}
@media (min-width: 60rem) {
.VPNavBar.has-sidebar .wrapper {
padding: 0;
}
}
.container {
display: flex;
justify-content: space-between;
@ -131,87 +182,67 @@ const { isHome, hasSidebar } = useLayout()
pointer-events: auto;
}
@media (min-width: 60rem) {
.VPNavBar.has-sidebar .container {
max-width: 100%;
}
.title {
/* below the overflow engine's range the title is the only shrinkable
piece, truncating instead of running over search and the hamburger */
min-width: 0;
}
.title {
flex-shrink: 0;
height: calc(var(--vp-nav-height) - 1px);
transition: background-color 0.5s;
@media (min-width: 48rem) {
.title {
/* the overflow engine measures fixed occupancy around a rigid title */
flex-shrink: 0;
}
}
@media (min-width: 60rem) {
.VPNavBar.has-sidebar .title {
position: absolute;
top: 0;
left: 0;
z-index: 2;
padding: 0 2rem;
width: var(--vp-sidebar-width);
height: var(--vp-nav-height);
background-color: transparent;
/* outside home the title column matches the sidebar column, so search and
menu sit at the same spot on every doc page; on home the title keeps its
natural width and search sits right next to it */
.VPNavBar:not(.home) .title {
min-width: calc(var(--vp-sidebar-width) - 2rem);
}
}
@media (min-width: 90rem) {
.VPNavBar.has-sidebar .title {
padding-left: max(2rem, calc((100% - (var(--vp-layout-max-width) - 4rem)) / 2));
width: calc((100% - (var(--vp-layout-max-width) - 4rem)) / 2 + var(--vp-sidebar-width) - 2rem);
max-width: calc(var(--vp-sidebar-width) - 2rem);
}
}
.content {
flex-grow: 1;
/* below the engine's range the controls stay rigid and the title absorbs
all the shrink */
flex-shrink: 0;
}
@media (min-width: 60rem) {
.VPNavBar.has-sidebar .content {
position: relative;
z-index: 1;
padding-left: var(--vp-sidebar-width);
padding-right: 2rem;
}
}
@media (min-width: 90rem) {
.VPNavBar.has-sidebar .content {
padding-left: calc((100% - var(--vp-layout-max-width)) / 2 + var(--vp-sidebar-width));
padding-right: calc((100% - var(--vp-layout-max-width)) / 2 + 2rem);
@media (min-width: 48rem) {
.content {
flex-shrink: 1;
min-width: 0;
}
}
.content-body {
position: relative;
display: flex;
justify-content: flex-end;
align-items: center;
height: var(--vp-nav-height);
transition: background-color 0.5s;
}
@media (min-width: 60rem) {
.VPNavBar:not(.home.top) .content-body {
position: relative;
background-color: var(--vp-nav-bg-color);
}
.VPNavBar:not(.has-sidebar):not(.home.top) .content-body {
background-color: transparent;
}
.content-body {
margin-right: -100vw;
padding-right: 100vw;
}
/* collapsed into the `` menu kept mounted (hidden, out of the a11y tree
and tab order) so its natural width stays measurable */
.content-body > .collapsed {
visibility: hidden;
position: absolute;
top: 0;
left: 0;
max-width: 100%;
overflow: hidden;
}
.menu + .translations::before,
.menu + .appearance::before,
.menu + .social-links::before,
.translations + .appearance::before,
.appearance + .social-links::before {
/* separators between whichever cluster units are currently in the bar */
.content-body > :where(.menu, .translations, .appearance, .social-links) + :where(.translations, .appearance, .social-links)::before {
margin-right: 0.5rem;
margin-left: 0.5rem;
width: 1px;
@ -220,12 +251,11 @@ const { isHome, hasSidebar } = useLayout()
content: "";
}
.menu + .appearance::before,
.translations + .appearance::before {
.content-body > :where(.menu, .translations) + .appearance::before {
margin-right: 1rem;
}
.appearance + .social-links::before {
.content-body > .appearance + .social-links::before {
margin-left: 1rem;
}
@ -234,20 +264,25 @@ const { isHome, hasSidebar } = useLayout()
}
.divider {
position: relative;
/* above the background surface, below the bar's content an open flyout
panel overlaps the bar's bottom edge and must cover the rule */
z-index: -1;
width: 100%;
height: 1px;
padding-left: var(--vp-nav-col-offset);
}
@media (min-width: 60rem) {
.VPNavBar.has-sidebar .divider {
padding-left: var(--vp-sidebar-width);
}
}
@media (min-width: 90rem) {
.VPNavBar.has-sidebar .divider {
padding-left: calc((100% - var(--vp-layout-max-width)) / 2 + var(--vp-sidebar-width));
}
/* the sidebar-column segment of the bottom rule inset from the column
edges so it lines up with the sidebar's own group dividers */
.VPNavBar.has-sidebar .divider::before {
content: "";
position: absolute;
top: 0;
left: calc(var(--vp-nav-col-offset) - var(--vp-sidebar-width) + 2rem);
width: calc(var(--vp-sidebar-width) - 4rem);
height: 1px;
background-color: var(--vp-c-divider);
}
.divider-line {
@ -257,20 +292,16 @@ const { isHome, hasSidebar } = useLayout()
}
.VPNavBar:not(.home) .divider-line {
background-color: var(--vp-c-gutter);
}
.VPNavBar.screen-open .divider-line {
background-color: var(--vp-c-divider);
background-color: var(--vp-nav-divider-color);
}
@media (min-width: 60rem) {
.divider-line {
transition: background-color 0.5s;
}
.VPNavBar:not(.home.top) .divider-line {
background-color: var(--vp-c-gutter);
background-color: var(--vp-nav-divider-color);
}
}
.VPNavBar.screen-open .divider-line {
background-color: var(--vp-c-divider);
}
</style>

@ -1,32 +0,0 @@
<script lang="ts" setup>
import { useData } from '../composables/data'
import VPSwitchAppearance from './VPSwitchAppearance.vue'
const { site } = useData()
</script>
<template>
<div
v-if="
site.appearance &&
site.appearance !== 'force-dark' &&
site.appearance !== 'force-auto'
"
class="VPNavBarAppearance"
>
<VPSwitchAppearance />
</div>
</template>
<style scoped>
.VPNavBarAppearance {
display: none;
}
@media (min-width: 80rem) {
.VPNavBarAppearance {
display: flex;
align-items: center;
}
}
</style>

@ -3,70 +3,84 @@ import { computed } from 'vue'
import { useData } from '../composables/data'
import { useLangs } from '../composables/langs'
import { useAppearanceSwitch } from '../composables/nav'
import { useNavOverflow } from '../composables/nav-overflow'
import VPFlyout from './VPFlyout.vue'
import VPMenuGroup from './VPMenuGroup.vue'
import VPMenuLink from './VPMenuLink.vue'
import VPNavAppearance from './VPNavAppearance.vue'
import VPNavTranslations from './VPNavTranslations.vue'
import VPSocialLinks from './VPSocialLinks.vue'
import VPSwitchAppearance from './VPSwitchAppearance.vue'
const { site, theme } = useData()
const { theme } = useData()
const { localeLinks, currentLang } = useLangs({
linkToCorrespondingPage: true
})
const hasAppearanceSwitch = useAppearanceSwitch()
const hasExtraContent = computed(
const overflow = useNavOverflow()
// nav items the priority+ engine pushed out of the bar (contiguous suffix)
const overflowItems = computed(() => {
const count = overflow?.state.visibleItemCount ?? Infinity
if (count === Infinity || !theme.value.nav) return []
return theme.value.nav.slice(count)
})
const showTranslations = computed(
() =>
(localeLinks.value.length && currentLang.value.label) ||
site.value.appearance ||
theme.value.socialLinks
!!(localeLinks.value.length && currentLang.value.label) &&
!(overflow?.state.translations ?? true)
)
const showAppearance = computed(
() => hasAppearanceSwitch.value && !(overflow?.state.appearance ?? true)
)
const showSocialLinks = computed(
() => !!theme.value.socialLinks && !(overflow?.state.socialLinks ?? true)
)
const hasContent = computed(
() =>
overflowItems.value.length > 0 ||
showTranslations.value ||
showAppearance.value ||
showSocialLinks.value
)
</script>
<template>
<VPFlyout
v-if="hasExtraContent"
v-if="hasContent"
class="VPNavBarExtra"
label="extra navigation"
:label="theme.extraMenuLabel || 'More options'"
:ref="(inst: any) => overflow?.setExtraEl(inst?.$el ?? null)"
>
<ul
v-if="localeLinks.length && currentLang.label"
class="group translations"
>
<li class="trans-title">{{ currentLang.label }}</li>
<template v-for="locale in localeLinks" :key="locale.link">
<VPMenuLink
:item="locale"
:external="false"
:lang="locale.lang"
:hreflang="locale.lang"
rel="alternate"
:dir="locale.dir"
data-allow-mismatch="attribute"
<ul v-if="overflowItems.length" class="group overflow-items">
<template v-for="item in overflowItems" :key="JSON.stringify(item)">
<VPMenuLink v-if="'link' in item" :item />
<!-- a menu panel is a vertical list context components must
render a flat list here, not a nested floating flyout -->
<component
v-else-if="'component' in item"
:is="item.component"
v-bind="item.props"
menu
/>
<VPMenuGroup v-else :text="item.text" :items="item.items" />
</template>
</ul>
<div
v-if="
site.appearance &&
site.appearance !== 'force-dark' &&
site.appearance !== 'force-auto'
"
class="group"
>
<div class="item appearance">
<p class="label">
{{ theme.darkModeSwitchLabel || 'Appearance' }}
</p>
<div class="appearance-action">
<VPSwitchAppearance />
</div>
</div>
<VPNavTranslations v-if="showTranslations" menu />
<div v-if="showAppearance" class="group">
<VPNavAppearance row />
</div>
<div v-if="theme.socialLinks" class="group">
<div v-if="showSocialLinks" class="group">
<div class="item social-links">
<VPSocialLinks class="social-links-list" :links="theme.socialLinks" />
<VPSocialLinks class="social-links-list" :links="theme.socialLinks!" />
</div>
</div>
</VPFlyout>
@ -84,35 +98,12 @@ const hasExtraContent = computed(
}
}
@media (min-width: 80rem) {
.VPNavBarExtra {
display: none;
}
}
.trans-title {
padding: 0 1.5rem 0 0.75rem;
line-height: 2.2857143;
font-size: 0.875rem;
font-weight: 700;
color: var(--vp-c-text-1);
}
.item.appearance,
.item.social-links {
display: flex;
align-items: center;
padding: 0 0.75rem;
}
.item.appearance {
min-width: 11rem;
}
.appearance-action {
margin-right: -0.125rem;
}
.social-links-list {
margin: -0.25rem -0.5rem;
}

@ -1,4 +1,9 @@
<script lang="ts" setup>
import { useTemplateRef, watchEffect } from 'vue'
import { useData } from '../composables/data'
import { useNav } from '../composables/nav'
defineProps<{
active: boolean
}>()
@ -6,19 +11,29 @@ defineProps<{
defineEmits<{
(e: 'click'): void
}>()
const { theme } = useData()
// register as the screen's trigger so Escape can return focus here
const el = useTemplateRef('el')
const { screenTriggerEl } = useNav()
watchEffect(() => {
screenTriggerEl.value = el.value
})
</script>
<template>
<button
ref="el"
type="button"
class="VPNavBarHamburger"
:class="{ active }"
aria-label="mobile navigation"
:aria-label="theme.mobileMenuLabel || 'Menu'"
:aria-expanded="active"
aria-controls="VPNavScreen"
@click="$emit('click')"
>
<span class="container">
<span class="container" aria-hidden="true">
<span class="top" />
<span class="middle" />
<span class="bottom" />

@ -1,46 +0,0 @@
<script lang="ts" setup>
import { useData } from '../composables/data'
import VPNavBarMenuGroup from './VPNavBarMenuGroup.vue'
import VPNavBarMenuLink from './VPNavBarMenuLink.vue'
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>
<ul class="list">
<li v-for="item in theme.nav" :key="JSON.stringify(item)">
<VPNavBarMenuLink v-if="'link' in item" :item />
<component
v-else-if="'component' in item"
:is="item.component"
v-bind="item.props"
/>
<VPNavBarMenuGroup v-else :item />
</li>
</ul>
</nav>
</template>
<style scoped>
.VPNavBarMenu {
display: none;
}
.list {
display: flex;
}
@media (min-width: 48rem) {
.VPNavBarMenu {
display: block;
}
}
</style>

@ -1,54 +0,0 @@
<script lang="ts" setup>
import { useRoute } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme'
import { computed } from 'vue'
import { isActive } from '../../shared'
import VPFlyout from './VPFlyout.vue'
const props = defineProps<{
item: DefaultTheme.NavItemWithChildren
}>()
const route = useRoute()
const isActiveGroup = computed(() => {
if (props.item.activeMatch) {
return isActive(
route.data.relativePath,
route.hash,
props.item.activeMatch,
true
)
}
return isChildActive(props.item)
})
function isChildActive(navItem: DefaultTheme.NavItem): boolean {
if ('component' in navItem) return false
if ('link' in navItem) {
const href =
typeof navItem.link === 'function'
? navItem.link(route.data)
: navItem.link
return isActive(
route.data.relativePath,
route.hash,
navItem.activeMatch || href,
!!navItem.activeMatch
)
}
return navItem.items.some(isChildActive)
}
</script>
<template>
<VPFlyout
:class="{ VPNavBarMenuGroup: true, active: isActiveGroup }"
:button="item.text"
:items="item.items"
/>
</template>

@ -1,47 +0,0 @@
<script lang="ts" setup>
import type { DefaultTheme } from 'vitepress/theme'
import { useNavItemLink } from '../composables/nav'
import VPLink from './VPLink.vue'
const props = defineProps<{
item: DefaultTheme.NavItemWithLink
}>()
const { href, isActiveLink, isCurrentLink } = useNavItemLink(() => props.item)
</script>
<template>
<VPLink
:class="{ VPNavBarMenuLink: true, active: isActiveLink }"
:aria-current="isCurrentLink ? 'page' : undefined"
:href
:target="item.target"
:rel="item.rel"
:no-icon="item.noIcon"
tabindex="0"
>
<span v-html="item.text"></span>
</VPLink>
</template>
<style scoped>
.VPNavBarMenuLink {
display: flex;
align-items: center;
padding: 0 0.75rem;
line-height: var(--vp-nav-height);
font-size: 0.875rem;
font-weight: 500;
color: var(--vp-c-text-1);
transition: color 0.25s;
}
.VPNavBarMenuLink.active {
color: var(--vp-c-brand-1);
}
.VPNavBarMenuLink:hover {
color: var(--vp-c-brand-1);
}
</style>

@ -204,7 +204,6 @@ function isEditingContent(event: KeyboardEvent): boolean {
@media (min-width: 48rem) {
.VPNavBarSearch {
gap: 0.5rem;
flex-grow: 1;
padding-left: 1.5rem;
}
}

@ -1,27 +0,0 @@
<script lang="ts" setup>
import { useData } from '../composables/data'
import VPSocialLinks from './VPSocialLinks.vue'
const { theme } = useData()
</script>
<template>
<VPSocialLinks
v-if="theme.socialLinks"
class="VPNavBarSocialLinks"
:links="theme.socialLinks"
/>
</template>
<style scoped>
.VPNavBarSocialLinks {
display: none;
}
@media (min-width: 80rem) {
.VPNavBarSocialLinks {
display: flex;
align-items: center;
}
}
</style>

@ -28,6 +28,14 @@ const target = computed(() =>
? undefined
: theme.value.logoLink?.target
)
// plain-text form of the rendered title (`siteTitle` may contain HTML),
// surfaced as a native tooltip since the sidebar column truncates it
const textTitle = computed(() => {
if (theme.value.siteTitle === false) return undefined
const raw = theme.value.siteTitle ?? site.value.title
return raw.replace(/<[^>]+>/g, '').trim() || undefined
})
</script>
<template>
@ -37,6 +45,7 @@ const target = computed(() =>
:href="link ?? normalizeLink(currentLang.link)"
:rel
:target
:title="textTitle"
>
<slot name="nav-bar-title-before" />
<VPImage v-if="theme.logo" class="logo" :image="theme.logo" />
@ -51,7 +60,6 @@ const target = computed(() =>
.title {
display: flex;
align-items: center;
border-bottom: 1px solid transparent;
width: 100%;
height: var(--vp-nav-height);
font-size: 1rem;
@ -60,17 +68,27 @@ const target = computed(() =>
transition: opacity 0.25s;
}
/* wherever the bar runs out of room, the title text gives way first */
.title > span {
overflow: hidden;
text-overflow: ellipsis;
}
@media (min-width: 60rem) {
.title {
flex-shrink: 0;
}
.VPNavBarTitle.has-sidebar .title {
border-bottom-color: var(--vp-c-divider);
/* the sidebar column has a fixed width truncate at the divider
segment's edge (2rem inset from the column); the outer column box
keeps its full reserved width */
.VPNavBarTitle.has-sidebar {
max-width: calc(var(--vp-sidebar-width) - 4rem);
}
}
:deep(.logo) {
flex: none;
margin-right: 0.5rem;
height: var(--vp-nav-logo-height);
}

@ -1,57 +0,0 @@
<script lang="ts" setup>
import { useData } from '../composables/data'
import { useLangs } from '../composables/langs'
import VPFlyout from './VPFlyout.vue'
import VPMenuLink from './VPMenuLink.vue'
const { theme } = useData()
const { localeLinks, currentLang } = useLangs({
linkToCorrespondingPage: true
})
</script>
<template>
<VPFlyout
v-if="localeLinks.length && currentLang.label"
class="VPNavBarTranslations"
icon="vpi-languages"
:label="theme.langMenuLabel || 'Change language'"
>
<ul class="items">
<li class="title">{{ currentLang.label }}</li>
<template v-for="locale in localeLinks" :key="locale.link">
<VPMenuLink
:item="locale"
:external="false"
:lang="locale.lang"
:hreflang="locale.lang"
rel="alternate"
:dir="locale.dir"
data-allow-mismatch="attribute"
/>
</template>
</ul>
</VPFlyout>
</template>
<style scoped>
.VPNavBarTranslations {
display: none;
}
@media (min-width: 80rem) {
.VPNavBarTranslations {
display: flex;
align-items: center;
}
}
.title {
padding: 0 1.5rem 0 0.75rem;
line-height: 2.2857143;
font-size: 0.875rem;
font-weight: 700;
color: var(--vp-c-text-1);
}
</style>

@ -0,0 +1,79 @@
<script lang="ts" setup>
import { useData } from '../composables/data'
import { useNavOverflow } from '../composables/nav-overflow'
import VPNavMenuGroup from './VPNavMenuGroup.vue'
import VPNavMenuLink from './VPNavMenuLink.vue'
const props = defineProps<{
screen?: boolean
}>()
const { theme } = useData()
// bar only inside the screen every item is always shown
const overflow = props.screen ? null : useNavOverflow()
function isVisible(index: number) {
return !overflow || index < overflow.state.visibleItemCount
}
</script>
<template>
<nav
v-if="theme.nav"
:aria-label="theme.navMenuLabel || 'Main Navigation'"
class="VPNavMenu"
:class="screen ? 'VPNavScreenMenu' : 'VPNavBarMenu'"
:ref="(el) => overflow?.setMenuEl(el as HTMLElement | null)"
>
<ul class="list">
<li
v-for="(item, index) in theme.nav"
:key="JSON.stringify(item)"
:class="{ collapsed: !isVisible(index) }"
:ref="(el) => overflow?.setItemEl(index, el as HTMLElement | null)"
>
<VPNavMenuLink v-if="'link' in item" :item :screen />
<component
v-else-if="'component' in item"
:is="item.component"
v-bind="item.props"
:screen-menu="screen || undefined"
/>
<VPNavMenuGroup v-else :item :screen />
</li>
</ul>
</nav>
</template>
<style scoped>
.VPNavBarMenu {
position: relative;
display: none;
min-width: 0;
}
.VPNavBarMenu .list {
display: flex;
justify-content: flex-end;
}
/* collapsed into the `` menu kept mounted (hidden, out of the a11y tree
and tab order) so its natural width stays measurable */
.VPNavBarMenu .list > li.collapsed {
visibility: hidden;
position: absolute;
top: 0;
left: 0;
max-width: 100%;
overflow: hidden;
}
@media (min-width: 48rem) {
.VPNavBarMenu {
display: flex;
flex-grow: 1;
justify-content: flex-end;
}
}
</style>

@ -0,0 +1,150 @@
<script lang="ts" setup>
import { useRoute } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme'
import { computed, ref, useId } from 'vue'
import { isActive } from '../../shared'
import VPFlyout from './VPFlyout.vue'
import VPMenuGroup from './VPMenuGroup.vue'
import VPMenuLink from './VPMenuLink.vue'
const props = defineProps<{
item: DefaultTheme.NavItemWithChildren
/** accordion inside the nav screen */
screen?: boolean
/** flat titled group inside a menu panel (e.g. the `⋯` menu) */
menu?: boolean
}>()
const route = useRoute()
const isActiveGroup = computed(() => {
if (props.item.activeMatch) {
return isActive(
route.data.relativePath,
route.hash,
props.item.activeMatch,
true
)
}
return isChildActive(props.item)
})
function isChildActive(navItem: DefaultTheme.NavItem): boolean {
if ('component' in navItem) return false
if ('link' in navItem) {
const href =
typeof navItem.link === 'function'
? navItem.link(route.data)
: navItem.link
return isActive(
route.data.relativePath,
route.hash,
navItem.activeMatch || href,
!!navItem.activeMatch
)
}
return navItem.items.some(isChildActive)
}
// screen accordion state resets when the screen unmounts
const isOpen = ref(false)
const groupId = useId()
function toggle() {
isOpen.value = !isOpen.value
}
</script>
<template>
<VPMenuGroup
v-if="menu"
class="VPNavMenuGroup"
:text="item.text"
:items="item.items"
/>
<VPFlyout
v-else-if="!screen"
:class="{ VPNavMenuGroup: true, VPNavBarMenuGroup: true, active: isActiveGroup }"
:button="item.text"
:items="item.items"
/>
<div
v-else
class="VPNavMenuGroup VPNavScreenMenuGroup"
:class="{ open: isOpen, active: isActiveGroup }"
>
<button
type="button"
class="button"
:aria-expanded="isOpen"
:aria-controls="groupId"
@click="toggle"
>
<span class="button-text" v-html="item.text"></span>
<span class="vpi-plus button-icon" aria-hidden="true" />
</button>
<ul v-show="isOpen" :id="groupId" class="items">
<template v-for="child in item.items" :key="JSON.stringify(child)">
<VPMenuLink v-if="'link' in child" :item="child" />
<li v-else-if="'component' in child">
<component :is="child.component" v-bind="child.props" screen-menu />
</li>
<VPMenuGroup v-else :text="child.text" :items="child.items" />
</template>
</ul>
</div>
</template>
<style scoped>
.VPNavScreenMenuGroup {
border-bottom: 1px solid var(--vp-c-divider);
transition: border-color 0.5s;
}
.VPNavScreenMenuGroup.open {
padding-bottom: 0.625rem;
}
.VPNavScreenMenuGroup.open .button {
padding-bottom: 0.375rem;
color: var(--vp-c-brand-1);
}
.VPNavScreenMenuGroup.open .button-icon {
/*rtl:ignore*/
transform: rotate(45deg);
}
.button {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0.25rem 0.6875rem 0;
width: 100%;
line-height: 1.7142857;
font-size: 0.875rem;
font-weight: 500;
color: var(--vp-c-text-1);
transition: color 0.25s;
}
.button:hover,
.VPNavScreenMenuGroup.active .button {
color: var(--vp-c-brand-1);
}
.button-icon {
transition: transform 0.25s;
}
.items :deep(.VPMenuGroup) {
padding-top: 0.25rem;
}
</style>

@ -7,44 +7,63 @@ import VPLink from './VPLink.vue'
const props = defineProps<{
item: DefaultTheme.NavItemWithLink
screen?: boolean
}>()
const { href, isActiveLink, isCurrentLink } = useNavItemLink(() => props.item)
const { closeScreen } = inject(navInjectionKey)!
const nav = inject(navInjectionKey, null)
function onClick() {
if (props.screen) nav?.closeScreen()
}
</script>
<template>
<VPLink
:class="{ VPNavScreenMenuLink: true, active: isActiveLink }"
class="VPNavMenuLink"
:class="{
VPNavBarMenuLink: !screen,
VPNavScreenMenuLink: screen,
active: isActiveLink
}"
:aria-current="isCurrentLink ? 'page' : undefined"
:href
:target="item.target"
:rel="item.rel"
:no-icon="item.noIcon"
@click="closeScreen"
@click="onClick"
>
<span v-html="item.text"></span>
</VPLink>
</template>
<style scoped>
.VPNavScreenMenuLink {
display: block;
border-bottom: 1px solid var(--vp-c-divider);
padding: 0.75rem 0 0.6875rem;
line-height: 1.7142857;
.VPNavMenuLink {
color: var(--vp-c-text-1);
font-size: 0.875rem;
font-weight: 500;
color: var(--vp-c-text-1);
transition: border-color 0.25s, color 0.25s;
transition: color 0.25s;
}
.VPNavScreenMenuLink:hover {
.VPNavMenuLink:hover,
.VPNavMenuLink.active {
color: var(--vp-c-brand-1);
}
.VPNavScreenMenuLink.active {
color: var(--vp-c-brand-1);
.VPNavBarMenuLink {
display: flex;
align-items: center;
min-height: var(--vp-nav-height);
padding: 0 0.75rem;
line-height: 1.5;
}
.VPNavScreenMenuLink {
display: block;
border-bottom: 1px solid var(--vp-c-divider);
padding: 0.75rem 0 0.6875rem;
line-height: 1.7142857;
transition: border-color 0.25s, color 0.25s;
}
</style>

@ -1,15 +1,29 @@
<script setup lang="ts">
import { onKeyStroke } from '@vueuse/core'
import { provide } from 'vue'
import { navScreenInjectionKey, useNav } from '../composables/nav'
import { useBodyScrollLock } from '../composables/scroll-lock'
import VPNavScreenAppearance from './VPNavScreenAppearance.vue'
import VPNavScreenMenu from './VPNavScreenMenu.vue'
import VPNavScreenSocialLinks from './VPNavScreenSocialLinks.vue'
import VPNavScreenTranslations from './VPNavScreenTranslations.vue'
import VPNavAppearance from './VPNavAppearance.vue'
import VPNavMenu from './VPNavMenu.vue'
import VPNavSocialLinks from './VPNavSocialLinks.vue'
import VPNavTranslations from './VPNavTranslations.vue'
defineProps<{
const props = defineProps<{
open: boolean
}>()
const isLocked = useBodyScrollLock()
provide(navScreenInjectionKey, true)
const { closeScreen, screenTriggerEl } = useNav()
onKeyStroke('Escape', () => {
if (!props.open) return
closeScreen()
screenTriggerEl.value?.focus()
})
</script>
<template>
@ -21,10 +35,10 @@ const isLocked = useBodyScrollLock()
<div v-if="open" class="VPNavScreen" id="VPNavScreen">
<div class="container">
<slot name="nav-screen-content-before" />
<VPNavScreenMenu class="menu" />
<VPNavScreenTranslations class="translations" />
<VPNavScreenAppearance class="appearance" />
<VPNavScreenSocialLinks class="social-links" />
<VPNavMenu screen class="menu" />
<VPNavTranslations screen class="translations" />
<VPNavAppearance row screen class="appearance" />
<VPNavSocialLinks screen class="social-links" />
<slot name="nav-screen-content-after" />
</div>
</div>

@ -1,40 +0,0 @@
<script lang="ts" setup>
import { useData } from '../composables/data'
import VPSwitchAppearance from './VPSwitchAppearance.vue'
const { site, theme } = useData()
</script>
<template>
<div
v-if="
site.appearance &&
site.appearance !== 'force-dark' &&
site.appearance !== 'force-auto'
"
class="VPNavScreenAppearance"
>
<p class="text">
{{ theme.darkModeSwitchLabel || 'Appearance' }}
</p>
<VPSwitchAppearance />
</div>
</template>
<style scoped>
.VPNavScreenAppearance {
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 0.5rem;
padding: 0.75rem 0.875rem 0.75rem 1rem;
background-color: var(--vp-c-bg-soft);
}
.text {
line-height: 2;
font-size: 0.75rem;
font-weight: 500;
color: var(--vp-c-text-2);
}
</style>

@ -1,28 +0,0 @@
<script lang="ts" setup>
import { useData } from '../composables/data'
import VPNavScreenMenuGroup from './VPNavScreenMenuGroup.vue'
import VPNavScreenMenuLink from './VPNavScreenMenuLink.vue'
const { theme } = useData()
</script>
<template>
<nav v-if="theme.nav" class="VPNavScreenMenu">
<ul>
<li v-for="item in theme.nav" :key="JSON.stringify(item)">
<VPNavScreenMenuLink v-if="'link' in item" :item />
<component
v-else-if="'component' in item"
:is="item.component"
v-bind="item.props"
screen-menu
/>
<VPNavScreenMenuGroup
v-else
:text="item.text || ''"
:items="item.items"
/>
</li>
</ul>
</nav>
</template>

@ -1,113 +0,0 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import VPNavScreenMenuGroupLink from './VPNavScreenMenuGroupLink.vue'
import VPNavScreenMenuGroupSection from './VPNavScreenMenuGroupSection.vue'
const props = defineProps<{
text: string
items: any[]
}>()
const isOpen = ref(false)
const groupId = computed(
() => `NavScreenGroup-${props.text.replace(' ', '-').toLowerCase()}`
)
function toggle() {
isOpen.value = !isOpen.value
}
</script>
<template>
<div class="VPNavScreenMenuGroup" :class="{ open: isOpen }">
<button
class="button"
:aria-controls="groupId"
:aria-expanded="isOpen"
@click="toggle"
>
<span class="button-text" v-html="text"></span>
<span class="vpi-plus button-icon" />
</button>
<ul :id="groupId" class="items">
<li v-for="item in items" :key="JSON.stringify(item)">
<div v-if="'link' in item" class="item">
<VPNavScreenMenuGroupLink :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" />
</div>
</li>
</ul>
</div>
</template>
<style scoped>
.VPNavScreenMenuGroup {
border-bottom: 1px solid var(--vp-c-divider);
height: 3rem;
overflow: hidden;
transition: border-color 0.5s;
}
.VPNavScreenMenuGroup .items {
visibility: hidden;
}
.VPNavScreenMenuGroup.open .items {
visibility: visible;
}
.VPNavScreenMenuGroup.open {
padding-bottom: 0.625rem;
height: auto;
}
.VPNavScreenMenuGroup.open .button {
padding-bottom: 0.375rem;
color: var(--vp-c-brand-1);
}
.VPNavScreenMenuGroup.open .button-icon {
/*rtl:ignore*/
transform: rotate(45deg);
}
.button {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0.25rem 0.6875rem 0;
width: 100%;
line-height: 1.7142857;
font-size: 0.875rem;
font-weight: 500;
color: var(--vp-c-text-1);
transition: color 0.25s;
}
.button:hover {
color: var(--vp-c-brand-1);
}
.button-icon {
transition: transform 0.25s;
}
.group:first-child {
padding-top: 0px;
}
.group + .group,
.group + .item {
padding-top: 0.25rem;
}
</style>

@ -1,49 +0,0 @@
<script lang="ts" setup>
import type { DefaultTheme } from 'vitepress/theme'
import { inject } from 'vue'
import { navInjectionKey, useNavItemLink } from '../composables/nav'
import VPLink from './VPLink.vue'
const props = defineProps<{
item: DefaultTheme.NavItemWithLink
}>()
const { href, isActiveLink, isCurrentLink } = useNavItemLink(() => props.item)
const { closeScreen } = inject(navInjectionKey)!
</script>
<template>
<VPLink
:class="{ VPNavScreenMenuGroupLink: true, active: isActiveLink }"
:aria-current="isCurrentLink ? 'page' : undefined"
:href
:target="item.target"
:rel="item.rel"
:no-icon="item.noIcon"
@click="closeScreen"
>
<span v-html="item.text"></span>
</VPLink>
</template>
<style scoped>
.VPNavScreenMenuGroupLink {
display: block;
margin-left: 0.75rem;
line-height: 2.2857143;
font-size: 0.875rem;
font-weight: 400;
color: var(--vp-c-text-1);
transition: color 0.25s;
}
.VPNavScreenMenuGroupLink:hover {
color: var(--vp-c-brand-1);
}
.VPNavScreenMenuGroupLink.active {
color: var(--vp-c-brand-1);
}
</style>

@ -1,35 +0,0 @@
<script lang="ts" setup>
import type { DefaultTheme } from 'vitepress/theme'
import VPNavScreenMenuGroupLink from './VPNavScreenMenuGroupLink.vue'
defineProps<{
text?: string
items: DefaultTheme.NavItemWithLink[]
}>()
</script>
<template>
<div class="VPNavScreenMenuGroupSection">
<p v-if="text" class="title">{{ text }}</p>
<ul>
<li v-for="item in items" :key="item.text">
<VPNavScreenMenuGroupLink :item="item" />
</li>
</ul>
</div>
</template>
<style scoped>
.VPNavScreenMenuGroupSection {
display: block;
}
.title {
line-height: 2.4615385;
font-size: 0.8125rem;
font-weight: 700;
color: var(--vp-c-text-2);
transition: color 0.25s;
}
</style>

@ -1,14 +0,0 @@
<script lang="ts" setup>
import { useData } from '../composables/data'
import VPSocialLinks from './VPSocialLinks.vue'
const { theme } = useData()
</script>
<template>
<VPSocialLinks
v-if="theme.socialLinks"
class="VPNavScreenSocialLinks"
:links="theme.socialLinks"
/>
</template>

@ -1,87 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useLangs } from '../composables/langs'
import VPLink from './VPLink.vue'
const { localeLinks, currentLang } = useLangs({
linkToCorrespondingPage: true
})
const isOpen = ref(false)
function toggle() {
isOpen.value = !isOpen.value
}
</script>
<template>
<div
v-if="localeLinks.length && currentLang.label"
class="VPNavScreenTranslations"
:class="{ open: isOpen }"
>
<button class="title" @click="toggle">
<span class="vpi-languages icon lang" />
{{ currentLang.label }}
<span class="vpi-chevron-down icon chevron" />
</button>
<ul class="list">
<li v-for="locale in localeLinks" :key="locale.link" class="item">
<VPLink
class="link"
:href="locale.link"
:external="false"
:lang="locale.lang"
:hreflang="locale.lang"
rel="alternate"
:dir="locale.dir"
data-allow-mismatch="attribute"
>
{{ locale.text }}
</VPLink>
</li>
</ul>
</div>
</template>
<style scoped>
.VPNavScreenTranslations {
height: 1.5rem;
overflow: hidden;
}
.VPNavScreenTranslations.open {
height: auto;
}
.title {
display: flex;
align-items: center;
font-size: 0.875rem;
font-weight: 500;
color: var(--vp-c-text-1);
}
.icon {
font-size: 1rem;
}
.icon.lang {
margin-right: 0.5rem;
}
.icon.chevron {
margin-left: 0.25rem;
}
.list {
padding: 0.25rem 0 0 1.5rem;
}
.link {
line-height: 2.4615385;
font-size: 0.8125rem;
color: var(--vp-c-text-1);
}
</style>

@ -0,0 +1,43 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { useData } from '../composables/data'
import { useNavOverflow } from '../composables/nav-overflow'
import VPSocialLinks from './VPSocialLinks.vue'
const props = defineProps<{
screen?: boolean
}>()
const { theme } = useData()
const overflow = props.screen ? null : useNavOverflow()
const isCollapsed = computed(() => !!overflow && !overflow.state.socialLinks)
</script>
<template>
<VPSocialLinks
v-if="theme.socialLinks"
class="VPNavSocialLinks"
:class="[
screen ? 'VPNavScreenSocialLinks' : 'VPNavBarSocialLinks',
{ collapsed: isCollapsed }
]"
:links="theme.socialLinks"
:ref="(inst: any) => overflow?.setClusterEl('socialLinks', inst?.$el ?? null)"
/>
</template>
<style scoped>
.VPNavBarSocialLinks {
display: none;
}
@media (min-width: 48rem) {
.VPNavBarSocialLinks {
display: flex;
align-items: center;
}
}
</style>

@ -0,0 +1,179 @@
<script lang="ts" setup>
import { computed, ref, useId } from 'vue'
import { useData } from '../composables/data'
import { useLangs } from '../composables/langs'
import { useNavOverflow } from '../composables/nav-overflow'
import VPFlyout from './VPFlyout.vue'
import VPLink from './VPLink.vue'
import VPMenuLink from './VPMenuLink.vue'
const props = defineProps<{
/** accordion inside the nav screen */
screen?: boolean
/** titled group inside the `⋯` menu */
menu?: boolean
}>()
const { theme } = useData()
const { localeLinks, currentLang } = useLangs({
linkToCorrespondingPage: true
})
const show = computed(
() => !!(localeLinks.value.length && currentLang.value.label)
)
// only the inline bar flyout participates in the overflow engine
const overflow = props.screen || props.menu ? null : useNavOverflow()
const isCollapsed = computed(
() => !!overflow && !overflow.state.translations
)
const isOpen = ref(false)
const listId = useId()
function toggle() {
isOpen.value = !isOpen.value
}
const localeProps = (locale: (typeof localeLinks.value)[number]) => ({
lang: locale.lang,
hreflang: locale.lang,
rel: 'alternate',
dir: locale.dir,
'data-allow-mismatch': 'attribute' as const
})
</script>
<template>
<!-- accordion inside the nav screen -->
<div
v-if="screen && show"
class="VPNavTranslations VPNavScreenTranslations"
:class="{ open: isOpen }"
>
<button
type="button"
class="title"
:aria-expanded="isOpen"
:aria-controls="listId"
@click="toggle"
>
<span class="vpi-languages icon lang" aria-hidden="true" />
{{ currentLang.label }}
<span class="vpi-chevron-down icon chevron" aria-hidden="true" />
</button>
<ul v-show="isOpen" :id="listId" class="list">
<li v-for="locale in localeLinks" :key="locale.link" class="item">
<VPLink
class="link"
:href="locale.link"
:external="false"
v-bind="localeProps(locale)"
>
{{ locale.text }}
</VPLink>
</li>
</ul>
</div>
<!-- titled group inside the `` menu -->
<div v-else-if="menu && show" class="VPNavTranslations group translations">
<p class="title">{{ currentLang.label }}</p>
<ul>
<template v-for="locale in localeLinks" :key="locale.link">
<VPMenuLink :item="locale" :external="false" v-bind="localeProps(locale)" />
</template>
</ul>
</div>
<!-- inline flyout in the navbar -->
<VPFlyout
v-else-if="!menu && show"
class="VPNavTranslations VPNavBarTranslations"
:class="{ collapsed: isCollapsed }"
icon="vpi-languages"
:label="theme.langMenuLabel || 'Change language'"
:ref="(inst: any) => overflow?.setClusterEl('translations', inst?.$el ?? null)"
>
<p class="title">{{ currentLang.label }}</p>
<ul class="items">
<template v-for="locale in localeLinks" :key="locale.link">
<VPMenuLink :item="locale" :external="false" v-bind="localeProps(locale)" />
</template>
</ul>
</VPFlyout>
</template>
<style scoped>
/* flyout variant */
.VPNavBarTranslations {
display: none;
}
@media (min-width: 48rem) {
.VPNavBarTranslations {
display: flex;
align-items: center;
}
}
.VPNavBarTranslations .title {
padding: 0 1.5rem 0 0.75rem;
line-height: 2.2857143;
font-size: 0.875rem;
font-weight: 700;
color: var(--vp-c-text-1);
}
/* menu-group variant (inside the `` menu) matches the menu group
titles */
.group > .title {
padding: 0 0.75rem;
line-height: 2.2857143;
font-size: 0.875rem;
font-weight: 600;
color: var(--vp-c-text-2);
}
/* accordion variant (inside the nav screen) */
.VPNavScreenTranslations .title {
display: flex;
align-items: center;
font-size: 0.875rem;
font-weight: 500;
color: var(--vp-c-text-1);
}
.VPNavScreenTranslations .icon {
font-size: 1rem;
}
.VPNavScreenTranslations .icon.lang {
margin-right: 0.5rem;
}
.VPNavScreenTranslations .icon.chevron {
margin-left: 0.25rem;
transition: transform 0.25s;
}
.VPNavScreenTranslations.open .icon.chevron {
transform: rotate(180deg);
}
.VPNavScreenTranslations .list {
padding: 0.25rem 0 0 1.5rem;
}
.VPNavScreenTranslations .link {
line-height: 2.4615385;
font-size: 0.8125rem;
color: var(--vp-c-text-1);
}
</style>

@ -32,22 +32,26 @@ const sectionTag = computed(() =>
props.item.text && textTag.value !== 'p' ? 'section' : 'div'
)
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()
}
</script>
<template>
<component :is="sectionTag" class="VPSidebarItem" :class="classes">
<component
:is="sectionTag"
class="VPSidebarItem"
:class="[
`level-${depth}`,
{
collapsible,
collapsed,
'is-link': isLink,
'is-active': isActiveLink,
'has-active': hasActiveLink
}
]"
>
<div v-if="item.text" class="item" @click="onItemClick">
<div class="indicator" />

@ -4,6 +4,11 @@ import { useTemplateRef, watch } from 'vue'
import { useData } from '../composables/data'
defineProps<{
// the component has two root nodes, so `inert` can't fall through
inert?: boolean
}>()
const { theme } = useData()
const route = useRoute()
const backToTop = useTemplateRef('backToTop')
@ -13,7 +18,7 @@ watch(() => route.path, () => backToTop.value?.focus())
<template>
<span ref="backToTop" tabindex="-1" />
<a href="#VPContent" class="VPSkipLink visually-hidden">
<a href="#VPContent" class="VPSkipLink visually-hidden" :inert>
{{ theme.skipToContentLabel || 'Skip to content' }}
</a>
</template>

@ -18,7 +18,7 @@
flex-shrink: 0;
border: 1px solid var(--vp-input-border-color);
background-color: var(--vp-input-switch-bg-color);
transition: border-color 0.25s !important;
transition: border-color 0.25s;
}
.VPSwitch:hover {
@ -35,7 +35,7 @@
border-radius: 50%;
background-color: var(--vp-c-neutral-inverse);
box-shadow: var(--vp-shadow-1);
transition: transform 0.25s !important;
transition: transform 0.25s;
}
.icon {
@ -58,6 +58,6 @@
.dark .icon :deep([class^='vpi-']) {
color: var(--vp-c-text-1);
transition: opacity 0.25s !important;
transition: opacity 0.25s;
}
</style>

@ -20,14 +20,16 @@ watchPostEffect(() => {
</script>
<template>
<!-- stable name + aria-checked state; the title stays the action hint -->
<VPSwitch
:title="switchTitle"
class="VPSwitchAppearance"
:aria-label="theme.darkModeSwitchLabel || 'Appearance'"
:aria-checked="isDark"
@click="toggleAppearance"
>
<span class="vpi-sun sun" />
<span class="vpi-moon moon" />
<span class="vpi-sun sun" aria-hidden="true" />
<span class="vpi-moon moon" aria-hidden="true" />
</VPSwitch>
</template>

@ -1,6 +1,5 @@
<script setup lang="ts">
import type { DefaultTheme } from 'vitepress/theme'
import { computed } from 'vue'
import VPTeamMembersItem from './VPTeamMembersItem.vue'
@ -12,12 +11,10 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
size: 'medium'
})
const classes = computed(() => [props.size, `count-${props.members.length}`])
</script>
<template>
<div class="VPTeamMembers" :class="classes">
<div class="VPTeamMembers" :class="[size, `count-${members.length}`]">
<ul class="container">
<li v-for="member in members" :key="member.name" class="item">
<VPTeamMembersItem :size :member />

@ -52,6 +52,7 @@ function activateFocusTracking() {
function deactivateFocusTracking() {
document.removeEventListener('focusin', handleFocusIn)
active = false
}
function handleFocusIn() {

@ -0,0 +1,299 @@
import { useMediaQuery } from '@vueuse/core'
import { inBrowser } from 'vitepress'
import {
inject,
onScopeDispose,
provide,
reactive,
toValue,
watch,
type InjectionKey,
type MaybeRefOrGetter
} from 'vue'
/**
* Priority+ overflow for the navbar (#1271, #2842).
*
* Collapse order under space pressure: social links appearance switch
* translations menu items right-to-left. Collapsed units move into the
* `` flyout (VPNavBarExtra) instead of being clipped, so nothing ever
* becomes unreachable.
*
* Collapsed units stay mounted but hidden (visibility: hidden + absolute,
* which also removes them from the a11y tree and tab order), so their
* natural widths remain measurable and observed re-expanding never works
* from stale data.
*/
export const navClusterUnits = [
'translations',
'appearance',
'socialLinks'
] as const
export type NavClusterUnit = (typeof navClusterUnits)[number]
export interface NavFitInput {
/** widths of the top-level menu items, in nav order (px) */
itemWidths: number[]
/** cluster unit widths; null = the unit isn't configured on this site */
translations: number | null
appearance: number | null
socialLinks: number | null
/** px available to all collapsible units */
available: number
/** px the `⋯` button occupies once anything is collapsed */
extraWidth: number
}
export interface NavFitResult {
/** number of menu items that stay in the bar (Infinity = all) */
visibleItemCount: number
translations: boolean
appearance: boolean
socialLinks: boolean
}
const allVisible: NavFitResult = {
visibleItemCount: Infinity,
translations: true,
appearance: true,
socialLinks: true
}
export function computeNavFit(input: NavFitInput): NavFitResult {
const { itemWidths, available, extraWidth } = input
const itemsTotal = itemWidths.reduce((sum, w) => sum + w, 0)
const clusterTotal =
(input.translations ?? 0) +
(input.appearance ?? 0) +
(input.socialLinks ?? 0)
if (itemsTotal + clusterTotal <= available) return allVisible
// something must collapse, so the `⋯` button needs room too
const budget = available - extraWidth
if (itemsTotal > budget) {
// even the menu alone doesn't fit — the whole cluster collapses and the
// menu keeps a contiguous prefix so the bar never shows a gap
// (units that aren't configured stay `true`: there is nothing to collapse)
let used = 0
let visibleItemCount = 0
for (const width of itemWidths) {
if (used + width > budget) break
used += width
visibleItemCount++
}
return {
visibleItemCount,
translations: input.translations == null,
appearance: input.appearance == null,
socialLinks: input.socialLinks == null
}
}
// menu fits in full; collapse the cluster in reverse keep-priority
// (translations is kept longest, social links go first) — once a unit
// collapses, everything after it in keep order collapses too
const result = { ...allVisible }
let used = itemsTotal
let dropRest = false
for (const unit of navClusterUnits) {
const width = input[unit]
if (width == null) continue
if (dropRest || used + width > budget) {
dropRest = true
result[unit] = false
} else {
used += width
}
}
return result
}
/** headroom against sub-pixel rounding and the inter-unit dividers */
const SLACK = 24
/** used until the real `⋯` button has been measured once */
const EXTRA_WIDTH_ESTIMATE = 48
export interface NavOverflow {
/** reactive collapse state, all-visible during SSR and below 48rem */
state: NavFitResult
/** true when anything is collapsed into the `⋯` menu */
hasCollapsed: () => boolean
setContainerEl(el: HTMLElement | null): void
setMenuEl(el: HTMLElement | null): void
setExtraEl(el: HTMLElement | null): void
setItemEl(index: number, el: HTMLElement | null): void
setClusterEl(unit: NavClusterUnit, el: HTMLElement | null): void
}
const navOverflowKey: InjectionKey<NavOverflow> = Symbol('nav-overflow')
export function useNavOverflow(): NavOverflow | null {
return inject(navOverflowKey, null)
}
export function provideNavOverflow(options: {
/** stringified nav config — collapse state resets when it changes */
itemsKey: MaybeRefOrGetter<string>
}): NavOverflow {
const state = reactive<NavFitResult>({ ...allVisible })
const itemEls = new Map<number, HTMLElement>()
const clusterEls = new Map<NavClusterUnit, HTMLElement>()
let containerEl: HTMLElement | null = null
let menuEl: HTMLElement | null = null
let extraEl: HTMLElement | null = null
let extraWidth = EXTRA_WIDTH_ESTIMATE
let observer: ResizeObserver | null = null
const observed = new Set<HTMLElement>()
let scheduled = false
function observe(el: HTMLElement | null) {
// instanceof also guards against fragment roots handing over a comment
// node via $el — better to skip a unit than to crash the bar
if (!inBrowser || !(el instanceof Element) || observed.has(el)) return
observed.add(el)
;(observer ??= new ResizeObserver(schedule)).observe(el)
}
function schedule() {
if (!inBrowser || scheduled) return
scheduled = true
requestAnimationFrame(() => {
scheduled = false
recompute()
})
}
const controller: NavOverflow = {
state,
hasCollapsed: () =>
state.visibleItemCount !== Infinity ||
!state.translations ||
!state.appearance ||
!state.socialLinks,
setContainerEl(el) {
containerEl = el
observe(el)
schedule()
},
setMenuEl(el) {
menuEl = el
observe(el)
schedule()
},
setExtraEl(el) {
extraEl = el
observe(el)
schedule()
},
setItemEl(index, el) {
el ? itemEls.set(index, el) : itemEls.delete(index)
// container and menu boxes don't resize when item content changes
// (the menu is flex-grown), so the items themselves are observed
observe(el)
schedule()
},
setClusterEl(unit, el) {
el ? clusterEls.set(unit, el) : clusterEls.delete(unit)
observe(el)
schedule()
}
}
provide(navOverflowKey, controller)
if (!inBrowser) return controller
// below tablet size the hamburger + screen own navigation and the bar only
// shows title/search/hamburger, so the engine idles in the all-visible state
const isEngineActive = useMediaQuery('(min-width: 48rem)')
// natural width even while the unit is collapsed (clamped by max-width)
function measureUnit(el: HTMLElement) {
return Math.max(el.offsetWidth, el.scrollWidth)
}
function recompute() {
if (!isEngineActive.value) return applyResult(allVisible)
if (!containerEl) return
if (extraEl && extraEl.offsetWidth > 0) extraWidth = extraEl.offsetWidth
// everything in the row that isn't a collapsible unit (search, slots,
// the hamburger…) is measured live and treated as fixed occupancy
let fixed = 0
for (const child of Array.from(containerEl.children)) {
if (!(child instanceof HTMLElement)) continue
if (child === menuEl || child === extraEl) continue
let isCluster = false
for (const el of clusterEls.values()) {
if (el === child) {
isCluster = true
break
}
}
if (isCluster) continue
fixed += child.offsetWidth
}
const itemWidths: number[] = []
for (let i = 0; i < itemEls.size; i++) {
const el = itemEls.get(i)
// a hole means not every item has registered yet — wait for the next
// pass instead of collapsing on partial data
if (!el) return
itemWidths.push(measureUnit(el))
}
const clusterWidth = (unit: NavClusterUnit) => {
const el = clusterEls.get(unit)
return el ? measureUnit(el) : null
}
applyResult(
computeNavFit({
itemWidths,
translations: clusterWidth('translations'),
appearance: clusterWidth('appearance'),
socialLinks: clusterWidth('socialLinks'),
available: containerEl.clientWidth - fixed - SLACK,
extraWidth
})
)
}
function applyResult(result: NavFitResult) {
if (state.visibleItemCount !== result.visibleItemCount)
state.visibleItemCount = result.visibleItemCount
for (const unit of navClusterUnits) {
if (state[unit] !== result[unit]) state[unit] = result[unit]
}
}
watch(isEngineActive, schedule)
watch(() => toValue(options.itemsKey), schedule)
if (document.fonts?.ready) {
// a clamped collapsed unit keeps its box size when the font changes, so
// the ResizeObserver alone can miss late font swaps
document.fonts.ready.then(schedule).catch(() => {})
}
onScopeDispose(() => {
observer?.disconnect()
observer = null
observed.clear()
})
return controller
}

@ -1,9 +1,10 @@
import { useMediaQuery, whenever } from '@vueuse/core'
import { useRoute } from 'vitepress'
import { inBrowser, useRoute } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme'
import {
computed,
ref,
shallowRef,
toValue,
watch,
type InjectionKey,
@ -11,37 +12,66 @@ import {
} from 'vue'
import { isActive } from '../../shared'
import { useData } from './data'
export function useNav() {
const isScreenOpen = ref(false)
// module-scoped so Layout.vue can render the rest of the app inert while the
// screen (mobile drawer) is open, without threading props through VPNav
const isScreenOpen = ref(false)
function openScreen() {
isScreenOpen.value = true
}
// the button that toggles the screen — focus returns to it when the screen
// is closed with Escape
const screenTriggerEl = shallowRef<HTMLButtonElement | null>(null)
function closeScreen() {
isScreenOpen.value = false
}
function openScreen() {
isScreenOpen.value = true
}
function toggleScreen() {
isScreenOpen.value ? closeScreen() : openScreen()
}
function closeScreen() {
isScreenOpen.value = false
}
// Close screen when the user resizes the window wider than tablet size.
const isTablet = useMediaQuery('(min-width: 48rem)')
whenever(isTablet, closeScreen)
function toggleScreen() {
isScreenOpen.value ? closeScreen() : openScreen()
}
const route = useRoute()
watch(() => route.path, closeScreen)
let watchersRegistered = false
export function useNav() {
// the auto-close watchers are app-wide, so guard against VPNav being
// mounted more than once (the flag persists across SSG renders, hence the
// inBrowser check — the watchers are meaningless during SSR anyway)
if (inBrowser && !watchersRegistered) {
watchersRegistered = true
// Close screen when the user resizes the window wider than tablet size.
const isTablet = useMediaQuery('(min-width: 48rem)')
whenever(isTablet, closeScreen)
const route = useRoute()
watch(() => route.path, closeScreen)
}
return {
isScreenOpen,
screenTriggerEl,
openScreen,
closeScreen,
toggleScreen
}
}
// whether the theme shows a light/dark switch (guard shared by the inline
// switch, the `⋯` menu and the nav screen)
export function useAppearanceSwitch() {
const { site } = useData()
return computed(
() =>
!!site.value.appearance &&
site.value.appearance !== 'force-dark' &&
site.value.appearance !== 'force-auto'
)
}
export function useNavItemLink(
item: MaybeRefOrGetter<DefaultTheme.NavItemWithLink>
) {
@ -76,3 +106,7 @@ export interface NavExposedMethods {
}
export const navInjectionKey: InjectionKey<NavExposedMethods> = Symbol('nav')
// true within the VPNavScreen subtree — shared components (VPMenuLink,
// VPMenuGroup, …) restyle themselves and close the screen on navigation
export const navScreenInjectionKey: InjectionKey<boolean> = Symbol('nav-screen')

@ -499,6 +499,20 @@
:root {
--vp-nav-height: 4rem;
--vp-nav-bg-color: var(--vp-c-bg);
/**
* Background while on top of the home page (unscrolled). Set it to
* var(--vp-nav-bg-color) to opt out of the transparent treatment.
*/
--vp-nav-home-bg-color: transparent;
/**
* Applied to the navbar surface, e.g. `saturate(180%) blur(8px)` combined
* with a translucent --vp-nav-bg-color for a frosted-glass bar.
*/
--vp-nav-backdrop-filter: none;
--vp-nav-divider-color: var(--vp-c-gutter);
--vp-nav-screen-bg-color: var(--vp-c-bg);
--vp-nav-logo-height: 1.5rem;
}
@ -516,7 +530,12 @@
* -------------------------------------------------------------------------- */
:root {
--vp-local-nav-bg-color: var(--vp-c-bg);
/**
* Follows the navbar surface by default, so a translucent
* --vp-nav-bg-color with --vp-nav-backdrop-filter extends the
* frosted-glass treatment to the local nav as well.
*/
--vp-local-nav-bg-color: var(--vp-nav-bg-color);
}
/**

@ -133,6 +133,29 @@ export namespace DefaultTheme {
*/
langMenuLabel?: string
/**
* Accessible label of the main navigation landmarks (navbar menu and
* the mobile menu).
*
* @default 'Main Navigation'
*/
navMenuLabel?: string
/**
* Set custom `aria-label` for the mobile menu (hamburger) button.
*
* @default 'Menu'
*/
mobileMenuLabel?: string
/**
* Set custom `aria-label` for the `` overflow menu button in the
* navbar, which collects nav items and controls that don't fit.
*
* @default 'More options'
*/
extraMenuLabel?: string
/**
* @default 'Skip to content'
*/

Loading…
Cancel
Save