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