mirror of https://github.com/vuejs/vitepress
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
parent
0f0fe13576
commit
26b76d6d9e
@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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,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>
|
|
||||||
@ -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>
|
|
||||||
@ -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>
|
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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
|
||||||
|
}
|
||||||
Loading…
Reference in new issue