fix(theme): prevent layout shifts when locking page scroll

fixes #5386
pull/5388/head
Divyansh Singh 3 weeks ago
parent 4f373d3186
commit 310679a76c

@ -1,10 +1,11 @@
<script setup lang="ts">
import { onKeyStroke, useScrollLock } from '@vueuse/core'
import { inBrowser, onContentUpdated } from 'vitepress'
import { onKeyStroke } from '@vueuse/core'
import { onContentUpdated } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme'
import { nextTick, ref, watch } from 'vue'
import { useData } from '../composables/data'
import { resolveTitle } from '../composables/outline'
import { useBodyScrollLock } from '../composables/scroll-lock'
import VPDocOutlineItem from './VPDocOutlineItem.vue'
const props = defineProps<{
@ -19,7 +20,7 @@ const main = ref<HTMLDivElement>()
const items = ref<HTMLDivElement>()
// lock body scroll while the dropdown is open to prevent scroll chaining
const isLocked = useScrollLock(inBrowser ? document.body : null)
const isLocked = useBodyScrollLock()
function closeOnClickOutside(e: Event) {
if (!main.value?.contains(e.target as Node)) {

@ -6,13 +6,12 @@ import {
onKeyStroke,
useEventListener,
useLocalStorage,
useScrollLock,
useSessionStorage
} from '@vueuse/core'
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
import Mark from 'mark.js/src/vanilla.js'
import MiniSearch, { type SearchResult } from 'minisearch'
import { dataSymbol, inBrowser, useRouter } from 'vitepress'
import { dataSymbol, useRouter } from 'vitepress'
import {
computed,
createApp,
@ -30,6 +29,7 @@ import type { LocalSearchTranslations } from '../../../../types/local-search'
import { pathToFile } from '../../app/utils'
import { escapeRegExp } from '../../shared'
import { useData } from '../composables/data'
import { useBodyScrollLock } from '../composables/scroll-lock'
import { LRUCache } from '../support/lru'
import { createSearchTranslate } from '../support/translation'
@ -409,7 +409,7 @@ useEventListener('popstate', (event) => {
/** Lock body */
const isLocked = useScrollLock(inBrowser ? document.body : null)
const isLocked = useBodyScrollLock()
onMounted(() => {
nextTick(() => {

@ -1,6 +1,5 @@
<script setup lang="ts">
import { useScrollLock } from '@vueuse/core'
import { inBrowser } from 'vitepress'
import { useBodyScrollLock } from '../composables/scroll-lock'
import VPNavScreenAppearance from './VPNavScreenAppearance.vue'
import VPNavScreenMenu from './VPNavScreenMenu.vue'
import VPNavScreenSocialLinks from './VPNavScreenSocialLinks.vue'
@ -10,7 +9,7 @@ defineProps<{
open: boolean
}>()
const isLocked = useScrollLock(inBrowser ? document.body : null)
const isLocked = useBodyScrollLock()
</script>
<template>
@ -45,6 +44,7 @@ const isLocked = useScrollLock(inBrowser ? document.body : null)
width: 100%;
background-color: var(--vp-nav-screen-bg-color);
overflow-y: auto;
overscroll-behavior: contain;
transition: background-color 0.25s;
pointer-events: auto;
}

@ -1,8 +1,7 @@
<script lang="ts" setup>
import { useScrollLock } from '@vueuse/core'
import { inBrowser } from 'vitepress'
import { ref, watch } from 'vue'
import { useLayout } from '../composables/layout'
import { useBodyScrollLock } from '../composables/scroll-lock'
import VPSidebarGroup from './VPSidebarGroup.vue'
const { sidebarGroups, hasSidebar } = useLayout()
@ -13,7 +12,7 @@ const props = defineProps<{
// a11y: focus Nav element when menu has opened
const navEl = ref<HTMLElement | null>(null)
const isLocked = useScrollLock(inBrowser ? document.body : null)
const isLocked = useBodyScrollLock()
watch(
[props, navEl],

@ -0,0 +1,141 @@
import { inBrowser } from 'vitepress'
import {
computed,
onScopeDispose,
shallowRef,
type WritableComputedRef
} from 'vue'
const isIOS =
inBrowser &&
(/iP(?:ad|hone|od)/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1))
const scrollKeys = new Set([
' ',
'PageUp',
'PageDown',
'Home',
'End',
'ArrowUp',
'ArrowDown',
'ArrowLeft',
'ArrowRight'
])
const listenerOptions = { capture: true, passive: false } as const
function isScrollable(target: EventTarget | null): boolean {
let el = target instanceof Element ? target : null
while (el && el !== document.body) {
const { overflowX, overflowY } = getComputedStyle(el)
if (
((overflowY === 'auto' || overflowY === 'scroll') &&
el.scrollHeight > el.clientHeight) ||
((overflowX === 'auto' || overflowX === 'scroll') &&
el.scrollWidth > el.clientWidth)
) {
return true
}
el = el.parentElement
}
return false
}
function blockScroll(e: Event) {
if ('touches' in e && (e as TouchEvent).touches.length > 1) return
if (!isScrollable(e.target)) e.preventDefault()
}
function blockScrollKeys(e: KeyboardEvent) {
if (e.metaKey || e.ctrlKey || e.altKey || !scrollKeys.has(e.key)) return
const el = e.target
if (
el instanceof HTMLElement &&
(el.isContentEditable || el.matches('input, textarea, select'))
) {
return
}
blockScroll(e)
}
// all instances lock the same element, so the style juggling is refcounted
let overflowLockCount = 0
let eventLockCount = 0
let initialOverflow: string | undefined
let initialGutter: string | undefined
function lockOverflow() {
if (++overflowLockCount > 1) return
const html = document.documentElement
if (!getComputedStyle(html).scrollbarGutter.includes('stable')) {
initialGutter = html.style.scrollbarGutter
html.style.scrollbarGutter = 'stable'
}
initialOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
if (isIOS) {
document.addEventListener('touchmove', blockScroll, listenerOptions)
}
}
function unlockOverflow() {
if (--overflowLockCount > 0) return
if (isIOS) {
document.removeEventListener('touchmove', blockScroll, listenerOptions)
}
document.body.style.overflow = initialOverflow ?? ''
if (initialGutter !== undefined) {
document.documentElement.style.scrollbarGutter = initialGutter
}
initialOverflow = initialGutter = undefined
}
function lockEvents() {
if (++eventLockCount > 1) return
document.addEventListener('wheel', blockScroll, listenerOptions)
document.addEventListener('touchmove', blockScroll, listenerOptions)
document.addEventListener('keydown', blockScrollKeys, listenerOptions)
}
function unlockEvents() {
if (--eventLockCount > 0) return
document.removeEventListener('wheel', blockScroll, listenerOptions)
document.removeEventListener('touchmove', blockScroll, listenerOptions)
document.removeEventListener('keydown', blockScrollKeys, listenerOptions)
}
/**
* Locks page scrolling behind an overlay.
*
* Prefer `scrollbar-gutter: stable` + `overflow: hidden` so layout width
* stays stable when the scrollbar is hidden. If unsupported, fall back to
* blocking scroll events while still allowing events inside scrollable
* elements.
*/
export function useBodyScrollLock(): WritableComputedRef<boolean> {
const isLocked = shallowRef(false)
let useEvents = false
function lock() {
if (isLocked.value) return
useEvents =
window.innerWidth > document.documentElement.clientWidth &&
!CSS.supports('scrollbar-gutter', 'stable')
useEvents ? lockEvents() : lockOverflow()
isLocked.value = true
}
function unlock() {
if (!isLocked.value) return
useEvents ? unlockEvents() : unlockOverflow()
isLocked.value = false
}
onScopeDispose(unlock)
return computed({
get: () => isLocked.value,
set: (value) => (value ? lock() : unlock())
})
}
Loading…
Cancel
Save