fix(@vben-core/composables): lock layout scroll container
This commit is contained in:
129
packages/@core/composables/src/__tests__/use-scroll-lock.test.ts
Normal file
129
packages/@core/composables/src/__tests__/use-scroll-lock.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user