From 9a5d052fa517b74c790f6422e8cd5141601c4f44 Mon Sep 17 00:00:00 2001 From: Dream <1012377328@qq.com> Date: Fri, 24 Jul 2026 16:56:35 +0800 Subject: [PATCH] fix(@vben-core/composables): lock layout scroll container --- .../src/__tests__/use-scroll-lock.test.ts | 129 ++++++++++++++++++ .../@core/composables/src/use-scroll-lock.ts | 128 ++++++++++++++--- 2 files changed, 236 insertions(+), 21 deletions(-) create mode 100644 packages/@core/composables/src/__tests__/use-scroll-lock.test.ts diff --git a/packages/@core/composables/src/__tests__/use-scroll-lock.test.ts b/packages/@core/composables/src/__tests__/use-scroll-lock.test.ts new file mode 100644 index 00000000..4e30ef9a --- /dev/null +++ b/packages/@core/composables/src/__tests__/use-scroll-lock.test.ts @@ -0,0 +1,129 @@ +import type { App, WritableComputedRef } from 'vue'; + +import { createApp, nextTick } from 'vue'; + +import { ELEMENT_ID_LAYOUT_SCROLL } from '@vben-core/shared/constants'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { SCROLL_FIXED_CLASS, useScrollLock } from '../use-scroll-lock'; + +let activeApp: App | undefined; + +function createScrollableElement(id?: string) { + const element = document.createElement('div'); + if (id) { + element.id = id; + } + element.style.overflow = 'auto'; + Object.defineProperties(element, { + clientHeight: { configurable: true, value: 100 }, + scrollHeight: { configurable: true, value: 120 }, + }); + document.body.append(element); + return element; +} + +function mountScrollLock(options?: { immediate?: boolean }) { + let scrollLock: undefined | WritableComputedRef; + const host = document.createElement('div'); + document.body.append(host); + + activeApp = createApp({ + setup() { + scrollLock = useScrollLock(options); + return () => null; + }, + }); + activeApp.mount(host); + + if (!scrollLock) { + throw new Error('useScrollLock was not initialized'); + } + return scrollLock; +} + +async function flushMountedLock() { + await nextTick(); + await nextTick(); +} + +afterEach(() => { + activeApp?.unmount(); + activeApp = undefined; + document.body.innerHTML = ''; + document.body.style.cssText = ''; + vi.restoreAllMocks(); +}); + +describe('useScrollLock', () => { + it('should lock the layout scroll element first', async () => { + const element = createScrollableElement(ELEMENT_ID_LAYOUT_SCROLL); + element.style.setProperty('scrollbar-gutter', 'auto'); + const scrollLock = mountScrollLock(); + + await flushMountedLock(); + + expect(scrollLock.value).toBe(true); + expect(element.style.overflow).toBe('hidden'); + expect(element.style.getPropertyValue('scrollbar-gutter')).toBe('stable'); + expect(document.body.style.overflow).not.toBe('hidden'); + + activeApp?.unmount(); + activeApp = undefined; + + expect(element.style.overflow).toBe('auto'); + expect(element.style.getPropertyValue('scrollbar-gutter')).toBe('auto'); + }); + + it('should support manual locking', async () => { + const element = createScrollableElement(ELEMENT_ID_LAYOUT_SCROLL); + const scrollLock = mountScrollLock({ immediate: false }); + + await flushMountedLock(); + expect(scrollLock.value).toBe(false); + + scrollLock.value = true; + expect(element.style.overflow).toBe('hidden'); + expect(element.style.getPropertyValue('scrollbar-gutter')).toBe('stable'); + + scrollLock.value = false; + expect(element.style.overflow).toBe('auto'); + expect(element.style.getPropertyValue('scrollbar-gutter')).toBe(''); + }); + + it('should fall back to body and compensate fixed nodes', async () => { + document.body.style.overflow = 'auto'; + Object.defineProperties(document.body, { + clientHeight: { configurable: true, value: 100 }, + scrollHeight: { configurable: true, value: 120 }, + }); + vi.spyOn(document.documentElement, 'scrollHeight', 'get').mockReturnValue( + 120, + ); + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(100); + vi.spyOn(window, 'getComputedStyle').mockReturnValue({ + overflowY: 'auto', + } as CSSStyleDeclaration); + + const fixedNode = document.createElement('div'); + fixedNode.className = SCROLL_FIXED_CLASS; + fixedNode.style.transition = 'opacity 200ms'; + document.body.append(fixedNode); + const scrollLock = mountScrollLock(); + + await flushMountedLock(); + + expect(scrollLock.value).toBe(true); + expect(document.body.style.overflow).toBe('hidden'); + expect(document.body.style.paddingRight).toBe('0px'); + expect(fixedNode.style.paddingRight).toBe('0px'); + + activeApp?.unmount(); + activeApp = undefined; + + expect(document.body.style.overflow).toBe('auto'); + expect(document.body.style.paddingRight).toBe(''); + expect(fixedNode.style.paddingRight).toBe(''); + }); +}); diff --git a/packages/@core/composables/src/use-scroll-lock.ts b/packages/@core/composables/src/use-scroll-lock.ts index d1c14975..1e176f53 100644 --- a/packages/@core/composables/src/use-scroll-lock.ts +++ b/packages/@core/composables/src/use-scroll-lock.ts @@ -1,4 +1,10 @@ -import { getScrollbarWidth, needsScrollbar } from '@vben-core/shared/utils'; +import { computed, nextTick, shallowRef } from 'vue'; + +import { + getLayoutScrollElement, + getScrollbarWidth, + needsScrollbar, +} from '@vben-core/shared/utils'; import { useScrollLock as _useScrollLock, @@ -8,20 +14,60 @@ import { export const SCROLL_FIXED_CLASS = `_scroll__fixed_`; -export function useScrollLock() { - const isLocked = _useScrollLock(document.body); - const scrollbarWidth = getScrollbarWidth(); +interface ScrollLockOptions { + immediate?: boolean; +} - tryOnMounted(() => { - if (!needsScrollbar()) { +function getScrollLockTarget() { + return getLayoutScrollElement() ?? document.body; +} + +function getLayoutFixedNodes() { + return [...document.querySelectorAll(`.${SCROLL_FIXED_CLASS}`)]; +} + +export function useScrollLock(options: ScrollLockOptions = {}) { + const { immediate = true } = options; + const lockTarget = shallowRef(null); + const isTargetLocked = _useScrollLock(lockTarget); + const scrollbarWidth = getScrollbarWidth(); + let hasScrollbarCompensation = false; + let hasScrollbarGutter = false; + + function applyScrollbarGutter(target: HTMLElement) { + if (target === document.body) { return; } - document.body.style.paddingRight = `${scrollbarWidth}px`; - const layoutFixedNodes = document.querySelectorAll( - `.${SCROLL_FIXED_CLASS}`, - ); - const nodes = [...layoutFixedNodes]; + target.dataset.scrollbarGutter = + target.style.getPropertyValue('scrollbar-gutter'); + target.style.setProperty('scrollbar-gutter', 'stable'); + hasScrollbarGutter = true; + } + + function resetScrollbarGutter(target: HTMLElement) { + if (!hasScrollbarGutter) { + return; + } + + const scrollbarGutter = target.dataset.scrollbarGutter; + if (scrollbarGutter) { + target.style.setProperty('scrollbar-gutter', scrollbarGutter); + } else { + target.style.removeProperty('scrollbar-gutter'); + } + delete target.dataset.scrollbarGutter; + hasScrollbarGutter = false; + } + + function applyScrollbarCompensation(target: HTMLElement) { + if (target !== document.body || !needsScrollbar()) { + return; + } + + target.style.paddingRight = `${scrollbarWidth}px`; + + const nodes = getLayoutFixedNodes(); if (nodes.length > 0) { nodes.forEach((node) => { node.dataset.transition = node.style.transition; @@ -29,18 +75,15 @@ export function useScrollLock() { node.style.paddingRight = `${scrollbarWidth}px`; }); } - isLocked.value = true; - }); + hasScrollbarCompensation = true; + } - tryOnBeforeUnmount(() => { - if (!needsScrollbar()) { + function resetScrollbarCompensation(target: HTMLElement) { + if (!hasScrollbarCompensation) { return; } - isLocked.value = false; - const layoutFixedNodes = document.querySelectorAll( - `.${SCROLL_FIXED_CLASS}`, - ); - const nodes = [...layoutFixedNodes]; + + const nodes = getLayoutFixedNodes(); if (nodes.length > 0) { nodes.forEach((node) => { node.style.paddingRight = ''; @@ -49,6 +92,49 @@ export function useScrollLock() { }); }); } - document.body.style.paddingRight = ''; + target.style.paddingRight = ''; + hasScrollbarCompensation = false; + } + + const isLocked = computed({ + get() { + return isTargetLocked.value; + }, + set(value: boolean) { + const target = lockTarget.value ?? getScrollLockTarget(); + lockTarget.value = target; + + if (value) { + if (isTargetLocked.value) { + return; + } + if (needsScrollbar(target)) { + applyScrollbarGutter(target); + applyScrollbarCompensation(target); + } + isTargetLocked.value = true; + return; + } + + isTargetLocked.value = false; + resetScrollbarCompensation(target); + resetScrollbarGutter(target); + }, }); + + tryOnMounted(async () => { + const target = getScrollLockTarget(); + lockTarget.value = target; + await nextTick(); + + if (immediate && needsScrollbar(target)) { + isLocked.value = true; + } + }); + + tryOnBeforeUnmount(() => { + isLocked.value = false; + }); + + return isLocked; }