@@ -18,35 +18,36 @@ import { useLangs } from "@vp-composables/langs";
1818const SIGN_IN_RE = / sign-in/ i ;
1919const EXTERNAL_URL_RE = / ^ (?:[a-z ] + :| \/\/ )/ i ;
2020
21- const { theme, frontmatter, isDark } = useData ();
22- const nav = computed (() => theme .value .nav ?? []);
21+ const { theme, frontmatter, isDark } = useData <DefaultTheme .Config >();
22+ const nav = computed <DefaultTheme .NavItem []>(() => theme .value .nav ?? []);
23+
24+ /** VitePress allows `link` to be a function; the header only handles string links. */
25+ type LinkNavItem = DefaultTheme .NavItemWithLink & { link: string };
26+ /** Any level of the nav tree, including the groups nested inside a dropdown. */
27+ type AnyNavItem = DefaultTheme .NavItem | DefaultTheme .NavItemChildren ;
28+
29+ const isLinkItem = (item : AnyNavItem ): item is LinkNavItem =>
30+ " link" in item && typeof item .link === " string" ;
2331
2432/**
2533 * Nav items flagged with `planeButton: "primary" | "secondary"` render as header
2634 * buttons (Sign in / cross-site link) instead of nav links. A `/sign-in` link is
2735 * treated as the primary button even without the flag.
2836 */
29- const isLinkItem = (item : DefaultTheme .NavItem ): item is DefaultTheme .NavItemWithLink =>
30- " link" in item && typeof item .link === " string" ;
31-
32- const isPrimaryButtonItem = (item : DefaultTheme .NavItem ) =>
37+ const isPrimaryButtonItem = (item : DefaultTheme .NavItem ): item is LinkNavItem =>
3338 isLinkItem (item ) && (item .planeButton === " primary" || SIGN_IN_RE .test (item .link ));
3439
35- const isSecondaryButtonItem = (item : DefaultTheme .NavItem ) =>
40+ const isSecondaryButtonItem = (item : DefaultTheme .NavItem ): item is LinkNavItem =>
3641 isLinkItem (item ) && item .planeButton === " secondary" ;
3742
3843const isNavButtonItem = (item : DefaultTheme .NavItem ) =>
3944 isPrimaryButtonItem (item ) || isSecondaryButtonItem (item );
4045
4146const mainNav = computed (() => nav .value .filter ((item ) => ! isNavButtonItem (item )));
4247
43- const signInNavItem = computed (() =>
44- nav .value .find ((item ): item is DefaultTheme .NavItemWithLink => isPrimaryButtonItem (item )),
45- );
48+ const signInNavItem = computed (() => nav .value .find (isPrimaryButtonItem ));
4649
47- const secondaryNavItem = computed (() =>
48- nav .value .find ((item ): item is DefaultTheme .NavItemWithLink => isSecondaryButtonItem (item )),
49- );
50+ const secondaryNavItem = computed (() => nav .value .find (isSecondaryButtonItem ));
5051
5152const route = useRoute ();
5253const { localeLinks, currentLang } = useLangs ({ correspondingLink: true });
@@ -68,7 +69,8 @@ const mobileMenuOpen = ref(false);
6869const expandedAccordions = ref <Set <number >>(new Set ());
6970const languageMenuOpen = ref (false );
7071
71- const isDropdown = (item : DefaultTheme .NavItem ) => " items" in item && Array .isArray (item .items );
72+ const isDropdown = (item : AnyNavItem ): item is DefaultTheme .NavItemWithChildren =>
73+ " items" in item && Array .isArray (item .items );
7274const isExternalLink = (link : string ) => EXTERNAL_URL_RE .test (link );
7375
7476const toggleAccordion = (index : number ) => {
@@ -86,23 +88,36 @@ const handleKeydown = (e: KeyboardEvent) => {
8688 }
8789};
8890
91+ /**
92+ * `position: fixed` on <body> drops the document scroll to 0, so remember the
93+ * offset and restore it on unlock — otherwise opening the mobile menu part-way
94+ * down a page sends the reader back to the top when they close it.
95+ */
96+ let lockedScrollY = 0 ;
97+
8998const lockBodyScroll = () => {
9099 const scrollbarWidth = window .innerWidth - document .documentElement .clientWidth ;
100+ lockedScrollY = window .scrollY ;
91101 document .body .style .overflow = " hidden" ;
92102 document .body .style .position = " fixed" ;
93103 document .body .style .width = " 100%" ;
94- document .body .style .top = " 0 " ;
104+ document .body .style .top = ` -${ lockedScrollY }px ` ;
95105 if (scrollbarWidth > 0 ) {
96106 document .body .style .paddingRight = ` ${scrollbarWidth }px ` ;
97107 }
98108};
99109
100110const unlockBodyScroll = () => {
111+ const wasLocked = document .body .style .position === " fixed" ;
101112 document .body .style .overflow = " " ;
102113 document .body .style .position = " " ;
103114 document .body .style .width = " " ;
104115 document .body .style .top = " " ;
105116 document .body .style .paddingRight = " " ;
117+ if (wasLocked ) {
118+ window .scrollTo (0 , lockedScrollY );
119+ lockedScrollY = 0 ;
120+ }
106121};
107122
108123const toggleMobileMenu = () => {
@@ -385,7 +400,7 @@ onUnmounted(() => {
385400 >
386401 <nav class =" flex-1 w-full pt-6 pb-8" >
387402 <ul class =" space-y-1" >
388- <li v-for =" (navItem, index) in mainNav" :key =" navItem.text " >
403+ <li v-for =" (navItem, index) in mainNav" :key =" index " >
389404 <template v-if =" isDropdown (navItem )" >
390405 <button
391406 type =" button"
@@ -407,11 +422,8 @@ onUnmounted(() => {
407422 </svg >
408423 </button >
409424 <ul v-show =" expandedAccordions.has(index)" class =" pl-4 space-y-1" >
410- <template
411- v-for =" childItem in navItem .items "
412- :key =" childItem .link || childItem .text "
413- >
414- <li v-if =" 'link' in childItem" >
425+ <template v-for =" (childItem , childIndex ) in navItem .items " :key =" childIndex " >
426+ <li v-if =" isLinkItem(childItem)" >
415427 <a
416428 :href =" normalizeLink(childItem.link)"
417429 :target =" isExternalLink(childItem.link) ? '_blank' : undefined"
@@ -429,7 +441,7 @@ onUnmounted(() => {
429441 {{ childItem.text }}
430442 </a >
431443 </li >
432- <li v-else-if =" 'items' in childItem" >
444+ <li v-else-if =" isDropdown( childItem) " >
433445 <p
434446 v-if =" childItem.text"
435447 class =" pt-3 pb-1 px-4 text-xs font-semibold uppercase tracking-wider text-grey/70 dark:text-white/50"
@@ -438,11 +450,11 @@ onUnmounted(() => {
438450 </p >
439451 <ul class =" pl-4 space-y-1" >
440452 <li
441- v-for =" nestedItem in childItem.items"
442- :key =" nestedItem.link || nestedItem.text "
453+ v-for =" ( nestedItem, nestedIndex) in childItem.items"
454+ :key =" nestedIndex "
443455 >
444456 <a
445- v-if =" nestedItem.link "
457+ v-if =" isLinkItem( nestedItem) "
446458 :href =" normalizeLink(nestedItem.link)"
447459 :target =" isExternalLink(nestedItem.link) ? '_blank' : undefined"
448460 :rel =" isExternalLink(nestedItem.link) ? 'noreferrer' : undefined"
@@ -465,7 +477,7 @@ onUnmounted(() => {
465477 </ul >
466478 </template >
467479 <a
468- v-else-if =" navItem.link "
480+ v-else-if =" isLinkItem( navItem) "
469481 :href =" normalizeLink(navItem.link)"
470482 :target =" isExternalLink(navItem.link) ? '_blank' : undefined"
471483 :rel =" isExternalLink(navItem.link) ? 'noreferrer' : undefined"
@@ -480,8 +492,9 @@ onUnmounted(() => {
480492 >
481493 {{ navItem.text }}
482494 </a >
495+ <!-- `{ component }` nav items have no mobile representation; skip them. -->
483496 <span
484- v-else
497+ v-else-if = " 'text' in navItem "
485498 class =" block py-3 px-4 text-base font-sans text-primary dark:text-white"
486499 >
487500 {{ navItem.text }}
0 commit comments