fix(@vben-core/composables): lock layout scroll container

This commit is contained in:
Dream
2026-07-24 16:56:35 +08:00
parent baea741432
commit 9a5d052fa5
2 changed files with 236 additions and 21 deletions

View File

@@ -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<boolean>;
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('');
});
});

View File

@@ -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<HTMLElement>(`.${SCROLL_FIXED_CLASS}`)];
}
export function useScrollLock(options: ScrollLockOptions = {}) {
const { immediate = true } = options;
const lockTarget = shallowRef<HTMLElement | null>(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<HTMLElement>(
`.${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<HTMLElement>(
`.${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;
}