Merge branch 'fork/dream-weave/fix-layout-scroll-container'
This commit is contained in:
@@ -9,6 +9,8 @@ export const CSS_VARIABLE_LAYOUT_FOOTER_HEIGHT = `--vben-footer-height`;
|
|||||||
|
|
||||||
/** 内容区域的组件ID */
|
/** 内容区域的组件ID */
|
||||||
export const ELEMENT_ID_MAIN_CONTENT = `__vben_main_content`;
|
export const ELEMENT_ID_MAIN_CONTENT = `__vben_main_content`;
|
||||||
|
/** layout 滚动容器ID */
|
||||||
|
export const ELEMENT_ID_LAYOUT_SCROLL = `__vben_layout_scroll`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh_CN 默认命名空间
|
* @zh_CN 默认命名空间
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { getElementVisibleRect } from '../dom';
|
import { ELEMENT_ID_LAYOUT_SCROLL } from '../../constants';
|
||||||
|
import {
|
||||||
|
getElementVisibleRect,
|
||||||
|
getLayoutScrollElement,
|
||||||
|
needsScrollbar,
|
||||||
|
} from '../dom';
|
||||||
|
|
||||||
describe('getElementVisibleRect', () => {
|
describe('getElementVisibleRect', () => {
|
||||||
// 设置浏览器视口尺寸的 mock
|
// 设置浏览器视口尺寸的 mock
|
||||||
@@ -15,6 +20,10 @@ describe('getElementVisibleRect', () => {
|
|||||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1000);
|
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('should return default rect if element is undefined', () => {
|
it('should return default rect if element is undefined', () => {
|
||||||
expect(getElementVisibleRect()).toEqual({
|
expect(getElementVisibleRect()).toEqual({
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
@@ -125,3 +134,75 @@ describe('getElementVisibleRect', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getLayoutScrollElement', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the layout scroll element', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.id = ELEMENT_ID_LAYOUT_SCROLL;
|
||||||
|
document.body.append(element);
|
||||||
|
|
||||||
|
expect(getLayoutScrollElement()).toBe(element);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when the layout scroll element is missing', () => {
|
||||||
|
expect(getLayoutScrollElement()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('needsScrollbar', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check scrollbar state from target element', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
vi.spyOn(element, 'clientHeight', 'get').mockReturnValue(100);
|
||||||
|
vi.spyOn(element, 'scrollHeight', 'get').mockReturnValue(120);
|
||||||
|
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
|
||||||
|
overflowY: 'auto',
|
||||||
|
} as CSSStyleDeclaration);
|
||||||
|
|
||||||
|
expect(needsScrollbar(element)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when target content does not overflow', () => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
vi.spyOn(element, 'clientHeight', 'get').mockReturnValue(100);
|
||||||
|
vi.spyOn(element, 'scrollHeight', 'get').mockReturnValue(100);
|
||||||
|
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
|
||||||
|
overflowY: 'auto',
|
||||||
|
} as CSSStyleDeclaration);
|
||||||
|
|
||||||
|
expect(needsScrollbar(element)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['clip', 'hidden'])(
|
||||||
|
'should ignore %s overflow targets',
|
||||||
|
(overflowY) => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
vi.spyOn(element, 'clientHeight', 'get').mockReturnValue(100);
|
||||||
|
vi.spyOn(element, 'scrollHeight', 'get').mockReturnValue(120);
|
||||||
|
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
|
||||||
|
overflowY,
|
||||||
|
} as CSSStyleDeclaration);
|
||||||
|
|
||||||
|
expect(needsScrollbar(element)).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('should fall back to document scrollbar state', () => {
|
||||||
|
vi.spyOn(document.documentElement, 'scrollHeight', 'get').mockReturnValue(
|
||||||
|
120,
|
||||||
|
);
|
||||||
|
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(100);
|
||||||
|
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
|
||||||
|
overflowY: 'auto',
|
||||||
|
} as CSSStyleDeclaration);
|
||||||
|
|
||||||
|
expect(needsScrollbar()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { ELEMENT_ID_LAYOUT_SCROLL } from '../constants/globals';
|
||||||
|
|
||||||
export interface VisibleDomRect {
|
export interface VisibleDomRect {
|
||||||
bottom: number;
|
bottom: number;
|
||||||
height: number;
|
height: number;
|
||||||
@@ -82,7 +84,24 @@ export function getScrollbarWidth() {
|
|||||||
return scrollbarWidth;
|
return scrollbarWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function needsScrollbar() {
|
export function getLayoutScrollElement() {
|
||||||
|
return document.querySelector<HTMLElement>(`#${ELEMENT_ID_LAYOUT_SCROLL}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function elementNeedsScrollbar(element: HTMLElement) {
|
||||||
|
const overflowY = window.getComputedStyle(element).overflowY;
|
||||||
|
if (overflowY === 'hidden' || overflowY === 'clip') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return element.scrollHeight > element.clientHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function needsScrollbar(target?: HTMLElement | null) {
|
||||||
|
if (target) {
|
||||||
|
return elementNeedsScrollbar(target);
|
||||||
|
}
|
||||||
|
|
||||||
const doc = document.documentElement;
|
const doc = document.documentElement;
|
||||||
const body = document.body;
|
const body = document.body;
|
||||||
|
|
||||||
|
|||||||
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 {
|
import {
|
||||||
useScrollLock as _useScrollLock,
|
useScrollLock as _useScrollLock,
|
||||||
@@ -8,20 +14,60 @@ import {
|
|||||||
|
|
||||||
export const SCROLL_FIXED_CLASS = `_scroll__fixed_`;
|
export const SCROLL_FIXED_CLASS = `_scroll__fixed_`;
|
||||||
|
|
||||||
export function useScrollLock() {
|
interface ScrollLockOptions {
|
||||||
const isLocked = _useScrollLock(document.body);
|
immediate?: boolean;
|
||||||
const scrollbarWidth = getScrollbarWidth();
|
}
|
||||||
|
|
||||||
tryOnMounted(() => {
|
function getScrollLockTarget() {
|
||||||
if (!needsScrollbar()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
document.body.style.paddingRight = `${scrollbarWidth}px`;
|
|
||||||
|
|
||||||
const layoutFixedNodes = document.querySelectorAll<HTMLElement>(
|
target.dataset.scrollbarGutter =
|
||||||
`.${SCROLL_FIXED_CLASS}`,
|
target.style.getPropertyValue('scrollbar-gutter');
|
||||||
);
|
target.style.setProperty('scrollbar-gutter', 'stable');
|
||||||
const nodes = [...layoutFixedNodes];
|
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) {
|
if (nodes.length > 0) {
|
||||||
nodes.forEach((node) => {
|
nodes.forEach((node) => {
|
||||||
node.dataset.transition = node.style.transition;
|
node.dataset.transition = node.style.transition;
|
||||||
@@ -29,18 +75,15 @@ export function useScrollLock() {
|
|||||||
node.style.paddingRight = `${scrollbarWidth}px`;
|
node.style.paddingRight = `${scrollbarWidth}px`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
isLocked.value = true;
|
hasScrollbarCompensation = true;
|
||||||
});
|
}
|
||||||
|
|
||||||
tryOnBeforeUnmount(() => {
|
function resetScrollbarCompensation(target: HTMLElement) {
|
||||||
if (!needsScrollbar()) {
|
if (!hasScrollbarCompensation) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isLocked.value = false;
|
|
||||||
const layoutFixedNodes = document.querySelectorAll<HTMLElement>(
|
const nodes = getLayoutFixedNodes();
|
||||||
`.${SCROLL_FIXED_CLASS}`,
|
|
||||||
);
|
|
||||||
const nodes = [...layoutFixedNodes];
|
|
||||||
if (nodes.length > 0) {
|
if (nodes.length > 0) {
|
||||||
nodes.forEach((node) => {
|
nodes.forEach((node) => {
|
||||||
node.style.paddingRight = '';
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { resolveHeaderHiddenOnScroll } from '../header-scroll-state';
|
||||||
|
|
||||||
|
const baseOptions = {
|
||||||
|
arrivedTop: false,
|
||||||
|
currentHidden: false,
|
||||||
|
directionDown: false,
|
||||||
|
directionUp: false,
|
||||||
|
headerHeight: 90,
|
||||||
|
scrollTop: 120,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('resolveHeaderHiddenOnScroll', () => {
|
||||||
|
it('should show the header near the top', () => {
|
||||||
|
expect(
|
||||||
|
resolveHeaderHiddenOnScroll({
|
||||||
|
...baseOptions,
|
||||||
|
directionDown: true,
|
||||||
|
scrollTop: 89,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show the header when the top is reached', () => {
|
||||||
|
expect(
|
||||||
|
resolveHeaderHiddenOnScroll({
|
||||||
|
...baseOptions,
|
||||||
|
arrivedTop: true,
|
||||||
|
currentHidden: true,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show the header while scrolling up', () => {
|
||||||
|
expect(
|
||||||
|
resolveHeaderHiddenOnScroll({
|
||||||
|
...baseOptions,
|
||||||
|
currentHidden: true,
|
||||||
|
directionUp: true,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should hide the header while scrolling down', () => {
|
||||||
|
expect(
|
||||||
|
resolveHeaderHiddenOnScroll({
|
||||||
|
...baseOptions,
|
||||||
|
directionDown: true,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve the current state without a direction', () => {
|
||||||
|
expect(
|
||||||
|
resolveHeaderHiddenOnScroll({
|
||||||
|
...baseOptions,
|
||||||
|
currentHidden: true,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,7 @@ const style = computed((): CSSProperties => {
|
|||||||
height: `${height}px`,
|
height: `${height}px`,
|
||||||
marginBottom: show ? '0' : `-${height}px`,
|
marginBottom: show ? '0' : `-${height}px`,
|
||||||
position: fixed ? 'fixed' : 'static',
|
position: fixed ? 'fixed' : 'static',
|
||||||
|
transform: show ? 'translateY(0)' : 'translateY(100%)',
|
||||||
width,
|
width,
|
||||||
zIndex,
|
zIndex,
|
||||||
};
|
};
|
||||||
@@ -37,7 +38,7 @@ const style = computed((): CSSProperties => {
|
|||||||
<template>
|
<template>
|
||||||
<footer
|
<footer
|
||||||
:style="style"
|
:style="style"
|
||||||
class="bottom-0 w-full bg-background-deep transition-all duration-200"
|
class="bottom-0 w-full shrink-0 bg-background-deep transition-all duration-200"
|
||||||
>
|
>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ import type { CSSProperties } from 'vue';
|
|||||||
|
|
||||||
import { computed, onUnmounted, shallowRef, useSlots, watchEffect } from 'vue';
|
import { computed, onUnmounted, shallowRef, useSlots, watchEffect } from 'vue';
|
||||||
|
|
||||||
|
import { useScrollLock } from '@vben-core/composables';
|
||||||
import { VbenScrollbar } from '@vben-core/shadcn-ui';
|
import { VbenScrollbar } from '@vben-core/shadcn-ui';
|
||||||
|
|
||||||
import { useScrollLock } from '@vueuse/core';
|
|
||||||
|
|
||||||
import { useSidebarDrag } from '../hooks/use-sidebar-drag';
|
import { useSidebarDrag } from '../hooks/use-sidebar-drag';
|
||||||
import { SidebarCollapseButton, SidebarFixedButton } from './widgets';
|
import { SidebarCollapseButton, SidebarFixedButton } from './widgets';
|
||||||
|
|
||||||
@@ -121,7 +120,7 @@ const expandOnHovering = defineModel<boolean>('expandOnHovering');
|
|||||||
const expandOnHover = defineModel<boolean>('expandOnHover');
|
const expandOnHover = defineModel<boolean>('expandOnHover');
|
||||||
const extraVisible = defineModel<boolean>('extraVisible');
|
const extraVisible = defineModel<boolean>('extraVisible');
|
||||||
|
|
||||||
const isLocked = useScrollLock(document.body);
|
const isLocked = useScrollLock({ immediate: false });
|
||||||
const slots = useSlots();
|
const slots = useSlots();
|
||||||
|
|
||||||
const asideRef = shallowRef<HTMLElement | null>(null);
|
const asideRef = shallowRef<HTMLElement | null>(null);
|
||||||
|
|||||||
28
packages/@core/ui-kit/layout-ui/src/header-scroll-state.ts
Normal file
28
packages/@core/ui-kit/layout-ui/src/header-scroll-state.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
interface HeaderScrollStateOptions {
|
||||||
|
arrivedTop: boolean;
|
||||||
|
currentHidden: boolean;
|
||||||
|
directionDown: boolean;
|
||||||
|
directionUp: boolean;
|
||||||
|
headerHeight: number;
|
||||||
|
scrollTop: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveHeaderHiddenOnScroll({
|
||||||
|
arrivedTop,
|
||||||
|
currentHidden,
|
||||||
|
directionDown,
|
||||||
|
directionUp,
|
||||||
|
headerHeight,
|
||||||
|
scrollTop,
|
||||||
|
}: HeaderScrollStateOptions) {
|
||||||
|
if (arrivedTop || scrollTop < headerHeight) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (directionUp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (directionDown) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return currentHidden;
|
||||||
|
}
|
||||||
@@ -12,9 +12,12 @@ import {
|
|||||||
} from '@vben-core/composables';
|
} from '@vben-core/composables';
|
||||||
import { IconifyIcon } from '@vben-core/icons';
|
import { IconifyIcon } from '@vben-core/icons';
|
||||||
import { VbenIconButton } from '@vben-core/shadcn-ui';
|
import { VbenIconButton } from '@vben-core/shadcn-ui';
|
||||||
import { ELEMENT_ID_MAIN_CONTENT } from '@vben-core/shared/constants';
|
import {
|
||||||
|
ELEMENT_ID_LAYOUT_SCROLL,
|
||||||
|
ELEMENT_ID_MAIN_CONTENT,
|
||||||
|
} from '@vben-core/shared/constants';
|
||||||
|
|
||||||
import { useMouse, useScroll, useThrottleFn } from '@vueuse/core';
|
import { useEventListener, useScroll } from '@vueuse/core';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
LayoutContent,
|
LayoutContent,
|
||||||
@@ -23,6 +26,7 @@ import {
|
|||||||
LayoutSidebar,
|
LayoutSidebar,
|
||||||
LayoutTabbar,
|
LayoutTabbar,
|
||||||
} from './components';
|
} from './components';
|
||||||
|
import { resolveHeaderHiddenOnScroll } from './header-scroll-state';
|
||||||
import { useLayout } from './hooks/use-layout';
|
import { useLayout } from './hooks/use-layout';
|
||||||
|
|
||||||
interface Props extends VbenLayoutProps {}
|
interface Props extends VbenLayoutProps {}
|
||||||
@@ -84,23 +88,26 @@ const sidebarExpandOnHover = defineModel<boolean>('sidebarExpandOnHover', {
|
|||||||
});
|
});
|
||||||
const sidebarEnable = defineModel<boolean>('sidebarEnable', { default: true });
|
const sidebarEnable = defineModel<boolean>('sidebarEnable', { default: true });
|
||||||
|
|
||||||
|
const HEADER_TRIGGER_DISTANCE = 12;
|
||||||
|
|
||||||
// side是否处于hover状态展开菜单中
|
// side是否处于hover状态展开菜单中
|
||||||
const sidebarExpandOnHovering = ref(false);
|
const sidebarExpandOnHovering = ref(false);
|
||||||
const headerIsHidden = ref(false);
|
const headerIsHidden = ref(false);
|
||||||
const contentRef = ref();
|
const mainRef = ref<HTMLElement | null>(null);
|
||||||
|
const contentRef = ref<HTMLElement | null>(null);
|
||||||
|
let lastMouseY: null | number = null;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
arrivedState,
|
arrivedState,
|
||||||
directions,
|
directions,
|
||||||
isScrolling,
|
|
||||||
y: scrollY,
|
y: scrollY,
|
||||||
} = useScroll(document);
|
} = useScroll(contentRef, {
|
||||||
|
onScroll: handleLayoutScroll,
|
||||||
|
});
|
||||||
|
|
||||||
const { setLayoutHeaderHeight } = useLayoutHeaderStyle();
|
const { setLayoutHeaderHeight } = useLayoutHeaderStyle();
|
||||||
const { setLayoutFooterHeight } = useLayoutFooterStyle();
|
const { setLayoutFooterHeight } = useLayoutFooterStyle();
|
||||||
|
|
||||||
const { y: mouseY } = useMouse({ target: contentRef, type: 'client' });
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
currentLayout,
|
currentLayout,
|
||||||
isFullContent,
|
isFullContent,
|
||||||
@@ -113,7 +120,19 @@ const {
|
|||||||
/**
|
/**
|
||||||
* 顶栏是否自动隐藏
|
* 顶栏是否自动隐藏
|
||||||
*/
|
*/
|
||||||
const isHeaderAutoMode = computed(() => props.headerMode === 'auto');
|
const isHeaderAutoActive = computed(
|
||||||
|
() =>
|
||||||
|
props.headerMode === 'auto' && !isMixedNav.value && !isFullContent.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isHeaderOverlayModeActive = computed(
|
||||||
|
() =>
|
||||||
|
(props.headerMode === 'auto' || props.headerMode === 'auto-scroll') &&
|
||||||
|
!isMixedNav.value &&
|
||||||
|
!isFullContent.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
const headerHasShadow = computed(() => scrollY.value > 20);
|
||||||
|
|
||||||
const headerWrapperHeight = computed(() => {
|
const headerWrapperHeight = computed(() => {
|
||||||
let height = 0;
|
let height = 0;
|
||||||
@@ -305,18 +324,38 @@ const tabbarStyle = computed((): CSSProperties => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const contentStyle = computed((): CSSProperties => {
|
const layoutScrollStyle = computed((): CSSProperties => {
|
||||||
const fixed = headerFixed.value;
|
const fixed = headerFixed.value;
|
||||||
|
|
||||||
const { footerEnable, footerFixed, footerHeight } = props;
|
if (!fixed) {
|
||||||
|
return {
|
||||||
|
marginTop: 0,
|
||||||
|
paddingTop: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHeaderOverlayModeActive.value) {
|
||||||
|
return {
|
||||||
|
marginTop: 0,
|
||||||
|
paddingTop: isFullContent.value ? 0 : `${headerWrapperHeight.value}px`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
marginTop:
|
marginTop:
|
||||||
fixed &&
|
fixed &&
|
||||||
!isFullContent.value &&
|
!isFullContent.value &&
|
||||||
!headerIsHidden.value &&
|
!headerIsHidden.value &&
|
||||||
(!isHeaderAutoMode.value || scrollY.value < headerWrapperHeight.value)
|
(!isHeaderAutoActive.value || scrollY.value < headerWrapperHeight.value)
|
||||||
? `${headerWrapperHeight.value}px`
|
? `${headerWrapperHeight.value}px`
|
||||||
: 0,
|
: 0,
|
||||||
|
paddingTop: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentStyle = computed((): CSSProperties => {
|
||||||
|
const { footerEnable, footerFixed, footerHeight } = props;
|
||||||
|
return {
|
||||||
paddingBottom: `${footerEnable && footerFixed ? footerHeight : 0}px`,
|
paddingBottom: `${footerEnable && footerFixed ? footerHeight : 0}px`,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -329,15 +368,19 @@ const headerZIndex = computed(() => {
|
|||||||
|
|
||||||
const headerWrapperStyle = computed((): CSSProperties => {
|
const headerWrapperStyle = computed((): CSSProperties => {
|
||||||
const fixed = headerFixed.value;
|
const fixed = headerFixed.value;
|
||||||
|
const hidden = headerIsHidden.value || isFullContent.value;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
height: isFullContent.value ? '0' : `${headerWrapperHeight.value}px`,
|
height: isFullContent.value ? '0' : `${headerWrapperHeight.value}px`,
|
||||||
left: isMixedNav.value ? 0 : mainStyle.value.sidebarAndExtraWidth,
|
left: isMixedNav.value ? 0 : mainStyle.value.sidebarAndExtraWidth,
|
||||||
position: fixed ? 'fixed' : 'static',
|
position: fixed ? 'fixed' : 'static',
|
||||||
top:
|
top: 0,
|
||||||
headerIsHidden.value || isFullContent.value
|
transform: fixed
|
||||||
? `-${headerWrapperHeight.value}px`
|
? `translate3d(0, ${hidden ? '-100%' : '0'}, 0)`
|
||||||
: 0,
|
: undefined,
|
||||||
|
transitionDuration: fixed ? undefined : '0ms',
|
||||||
width: mainStyle.value.width,
|
width: mainStyle.value.width,
|
||||||
|
willChange: fixed ? 'transform' : undefined,
|
||||||
'z-index': headerZIndex.value,
|
'z-index': headerZIndex.value,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -426,68 +469,76 @@ watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
{
|
watch(
|
||||||
const HEADER_TRIGGER_DISTANCE = 12;
|
[() => props.headerMode, () => isMixedNav.value, () => isFullContent.value],
|
||||||
|
() => {
|
||||||
|
headerIsHidden.value = false;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
watch(
|
useEventListener(mainRef, 'mousemove', handleHeaderMouseMove, {
|
||||||
[() => props.headerMode, () => mouseY.value, () => headerIsHidden.value],
|
passive: true,
|
||||||
() => {
|
});
|
||||||
if (!isHeaderAutoMode.value || isMixedNav.value || isFullContent.value) {
|
useEventListener(mainRef, 'wheel', handleLayoutWheel, {
|
||||||
if (props.headerMode !== 'auto-scroll') {
|
passive: true,
|
||||||
headerIsHidden.value = false;
|
});
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isInTriggerZone = mouseY.value <= HEADER_TRIGGER_DISTANCE;
|
function handleLayoutWheel(event: WheelEvent) {
|
||||||
const isInHeaderZone =
|
lastMouseY = event.clientY;
|
||||||
!headerIsHidden.value && mouseY.value <= headerWrapperHeight.value;
|
|
||||||
|
|
||||||
headerIsHidden.value = !(isInTriggerZone || isInHeaderZone);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
immediate: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
function handleHeaderMouseMove(event: MouseEvent) {
|
||||||
const checkHeaderIsHidden = useThrottleFn((top, bottom, topArrived) => {
|
lastMouseY = event.clientY;
|
||||||
if (scrollY.value < headerWrapperHeight.value) {
|
|
||||||
headerIsHidden.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (topArrived) {
|
|
||||||
headerIsHidden.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (top) {
|
if (!isHeaderAutoActive.value) {
|
||||||
headerIsHidden.value = false;
|
return;
|
||||||
} else if (bottom) {
|
}
|
||||||
headerIsHidden.value = true;
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
watch(
|
updateHeaderVisibilityFromMouse(lastMouseY);
|
||||||
() => scrollY.value,
|
}
|
||||||
() => {
|
|
||||||
if (
|
function updateHeaderVisibilityFromMouse(mouseY: null | number) {
|
||||||
props.headerMode !== 'auto-scroll' ||
|
if (arrivedState.top || scrollY.value < headerWrapperHeight.value) {
|
||||||
isMixedNav.value ||
|
headerIsHidden.value = false;
|
||||||
isFullContent.value
|
return;
|
||||||
) {
|
}
|
||||||
return;
|
|
||||||
}
|
if (mouseY === null) {
|
||||||
if (isScrolling.value) {
|
return;
|
||||||
checkHeaderIsHidden(
|
}
|
||||||
directions.top,
|
|
||||||
directions.bottom,
|
const isInTriggerZone = mouseY <= HEADER_TRIGGER_DISTANCE;
|
||||||
arrivedState.top,
|
const isInHeaderZone =
|
||||||
);
|
!headerIsHidden.value && mouseY <= headerWrapperHeight.value;
|
||||||
}
|
|
||||||
},
|
headerIsHidden.value = !(isInTriggerZone || isInHeaderZone);
|
||||||
);
|
}
|
||||||
|
|
||||||
|
function handleLayoutScroll() {
|
||||||
|
if (isHeaderAutoActive.value) {
|
||||||
|
updateHeaderVisibilityFromMouse(lastMouseY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
props.headerMode !== 'auto-scroll' ||
|
||||||
|
isMixedNav.value ||
|
||||||
|
isFullContent.value
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolveHeaderVisibilityOnScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHeaderVisibilityOnScroll() {
|
||||||
|
headerIsHidden.value = resolveHeaderHiddenOnScroll({
|
||||||
|
arrivedTop: arrivedState.top,
|
||||||
|
currentHidden: headerIsHidden.value,
|
||||||
|
directionDown: directions.bottom,
|
||||||
|
directionUp: directions.top,
|
||||||
|
headerHeight: headerWrapperHeight.value,
|
||||||
|
scrollTop: scrollY.value,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClickMask() {
|
function handleClickMask() {
|
||||||
@@ -503,10 +554,13 @@ function handleHeaderToggle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const idMainContent = ELEMENT_ID_MAIN_CONTENT;
|
const idMainContent = ELEMENT_ID_MAIN_CONTENT;
|
||||||
|
const idLayoutScroll = ELEMENT_ID_LAYOUT_SCROLL;
|
||||||
|
const idLayoutStaticHeader = `${ELEMENT_ID_LAYOUT_SCROLL}__static_header`;
|
||||||
|
const layoutStaticHeaderTarget = `#${idLayoutStaticHeader}`;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative flex min-h-full w-full">
|
<div class="relative flex h-full min-h-0 w-full overflow-hidden">
|
||||||
<LayoutSidebar
|
<LayoutSidebar
|
||||||
v-if="sidebarEnableState"
|
v-if="sidebarEnableState"
|
||||||
v-model:draggable="sidebarDraggable"
|
v-model:draggable="sidebarDraggable"
|
||||||
@@ -556,87 +610,96 @@ const idMainContent = ELEMENT_ID_MAIN_CONTENT;
|
|||||||
</LayoutSidebar>
|
</LayoutSidebar>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref="contentRef"
|
ref="mainRef"
|
||||||
class="flex flex-1 flex-col overflow-hidden transition-all duration-300 ease-in"
|
class="relative flex min-h-0 flex-1 flex-col overflow-hidden transition-all duration-300 ease-in"
|
||||||
>
|
>
|
||||||
|
<Teleport defer :disabled="headerFixed" :to="layoutStaticHeaderTarget">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
{
|
||||||
|
'shadow-[0_16px_24px_hsl(var(--background))]': headerHasShadow,
|
||||||
|
},
|
||||||
|
SCROLL_FIXED_CLASS,
|
||||||
|
]"
|
||||||
|
:style="headerWrapperStyle"
|
||||||
|
class="shrink-0 overflow-hidden transition-[transform,left,width] duration-200"
|
||||||
|
>
|
||||||
|
<LayoutHeader
|
||||||
|
v-if="headerVisible"
|
||||||
|
:full-width="!isSideMode"
|
||||||
|
:height="headerHeight"
|
||||||
|
:is-mobile="isMobile"
|
||||||
|
:show="!isFullContent && !headerHidden"
|
||||||
|
:sidebar-width="sidebarWidth"
|
||||||
|
:theme="headerTheme"
|
||||||
|
:width="mainStyle.width"
|
||||||
|
:z-index="headerZIndex"
|
||||||
|
:logo-visible="sidebarLogoVisible"
|
||||||
|
>
|
||||||
|
<template v-if="showHeaderLogo" #logo>
|
||||||
|
<slot name="logo"></slot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #toggle-button>
|
||||||
|
<VbenIconButton
|
||||||
|
v-if="showHeaderToggleButton"
|
||||||
|
class="my-0 mr-1 rounded-md"
|
||||||
|
@click="handleHeaderToggle"
|
||||||
|
>
|
||||||
|
<IconifyIcon v-if="showSidebar" icon="ep:fold" />
|
||||||
|
<IconifyIcon v-else icon="ep:expand" />
|
||||||
|
</VbenIconButton>
|
||||||
|
</template>
|
||||||
|
<slot name="header"></slot>
|
||||||
|
</LayoutHeader>
|
||||||
|
|
||||||
|
<LayoutTabbar
|
||||||
|
v-if="tabbarEnable"
|
||||||
|
:height="tabbarHeight"
|
||||||
|
:style="tabbarStyle"
|
||||||
|
>
|
||||||
|
<slot name="tabbar"></slot>
|
||||||
|
</LayoutTabbar>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
:class="[
|
:id="idLayoutScroll"
|
||||||
{
|
ref="contentRef"
|
||||||
'shadow-[0_16px_24px_hsl(var(--background))]': scrollY > 20,
|
:style="layoutScrollStyle"
|
||||||
},
|
class="flex min-h-0 flex-1 flex-col overflow-x-hidden overflow-y-auto"
|
||||||
SCROLL_FIXED_CLASS,
|
|
||||||
]"
|
|
||||||
:style="headerWrapperStyle"
|
|
||||||
class="overflow-hidden transition-all duration-200"
|
|
||||||
>
|
>
|
||||||
<LayoutHeader
|
<div :id="idLayoutStaticHeader" class="contents"></div>
|
||||||
v-if="headerVisible"
|
|
||||||
:full-width="!isSideMode"
|
|
||||||
:height="headerHeight"
|
|
||||||
:is-mobile="isMobile"
|
|
||||||
:show="!isFullContent && !headerHidden"
|
|
||||||
:sidebar-width="sidebarWidth"
|
|
||||||
:theme="headerTheme"
|
|
||||||
:width="mainStyle.width"
|
|
||||||
:z-index="headerZIndex"
|
|
||||||
:logo-visible="sidebarLogoVisible"
|
|
||||||
>
|
|
||||||
<template v-if="showHeaderLogo" #logo>
|
|
||||||
<slot name="logo"></slot>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #toggle-button>
|
<LayoutContent
|
||||||
<VbenIconButton
|
:id="idMainContent"
|
||||||
v-if="showHeaderToggleButton"
|
:content-compact="contentCompact"
|
||||||
class="my-0 mr-1 rounded-md"
|
:content-compact-width="contentCompactWidth"
|
||||||
@click="handleHeaderToggle"
|
:padding="contentPadding"
|
||||||
>
|
:padding-bottom="contentPaddingBottom"
|
||||||
<IconifyIcon v-if="showSidebar" icon="ep:fold" />
|
:padding-left="contentPaddingLeft"
|
||||||
<IconifyIcon v-else icon="ep:expand" />
|
:padding-right="contentPaddingRight"
|
||||||
</VbenIconButton>
|
:padding-top="contentPaddingTop"
|
||||||
</template>
|
:style="contentStyle"
|
||||||
<slot name="header"></slot>
|
|
||||||
</LayoutHeader>
|
|
||||||
|
|
||||||
<LayoutTabbar
|
|
||||||
v-if="tabbarEnable"
|
|
||||||
:height="tabbarHeight"
|
|
||||||
:style="tabbarStyle"
|
|
||||||
>
|
>
|
||||||
<slot name="tabbar"></slot>
|
<slot name="content"></slot>
|
||||||
</LayoutTabbar>
|
|
||||||
|
<template #overlay>
|
||||||
|
<slot name="content-overlay"></slot>
|
||||||
|
</template>
|
||||||
|
</LayoutContent>
|
||||||
|
|
||||||
|
<LayoutFooter
|
||||||
|
v-if="footerEnable"
|
||||||
|
:fixed="footerFixed"
|
||||||
|
:height="footerHeight"
|
||||||
|
:show="!isFullContent"
|
||||||
|
:width="footerWidth"
|
||||||
|
:z-index="zIndex"
|
||||||
|
>
|
||||||
|
<slot name="footer"></slot>
|
||||||
|
</LayoutFooter>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- </div> -->
|
|
||||||
<LayoutContent
|
|
||||||
:id="idMainContent"
|
|
||||||
:content-compact="contentCompact"
|
|
||||||
:content-compact-width="contentCompactWidth"
|
|
||||||
:padding="contentPadding"
|
|
||||||
:padding-bottom="contentPaddingBottom"
|
|
||||||
:padding-left="contentPaddingLeft"
|
|
||||||
:padding-right="contentPaddingRight"
|
|
||||||
:padding-top="contentPaddingTop"
|
|
||||||
:style="contentStyle"
|
|
||||||
class="transition-[margin-top] duration-200"
|
|
||||||
>
|
|
||||||
<slot name="content"></slot>
|
|
||||||
|
|
||||||
<template #overlay>
|
|
||||||
<slot name="content-overlay"></slot>
|
|
||||||
</template>
|
|
||||||
</LayoutContent>
|
|
||||||
|
|
||||||
<LayoutFooter
|
|
||||||
v-if="footerEnable"
|
|
||||||
:fixed="footerFixed"
|
|
||||||
:height="footerHeight"
|
|
||||||
:show="!isFullContent"
|
|
||||||
:width="footerWidth"
|
|
||||||
:z-index="zIndex"
|
|
||||||
>
|
|
||||||
<slot name="footer"></slot>
|
|
||||||
</LayoutFooter>
|
|
||||||
</div>
|
</div>
|
||||||
<slot name="extra"></slot>
|
<slot name="extra"></slot>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import type { App } from 'vue';
|
||||||
|
import type { NavigationGuard, NavigationHookAfter, Router } from 'vue-router';
|
||||||
|
|
||||||
|
import { createApp } from 'vue';
|
||||||
|
|
||||||
|
import { ELEMENT_ID_LAYOUT_SCROLL } from '@vben-core/shared/constants';
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { useLayoutScroll } from '../use-layout-scroll';
|
||||||
|
|
||||||
|
let activeApp: App | undefined;
|
||||||
|
|
||||||
|
function createRouterMock() {
|
||||||
|
let afterHook: NavigationHookAfter | undefined;
|
||||||
|
let beforeGuard: NavigationGuard | undefined;
|
||||||
|
const removeAfterHook = vi.fn();
|
||||||
|
const removeBeforeGuard = vi.fn();
|
||||||
|
|
||||||
|
const router = {
|
||||||
|
afterEach: vi.fn((hook: NavigationHookAfter) => {
|
||||||
|
afterHook = hook;
|
||||||
|
return removeAfterHook;
|
||||||
|
}),
|
||||||
|
beforeEach: vi.fn((guard: NavigationGuard) => {
|
||||||
|
beforeGuard = guard;
|
||||||
|
return removeBeforeGuard;
|
||||||
|
}),
|
||||||
|
} as unknown as Router;
|
||||||
|
|
||||||
|
function getHooks() {
|
||||||
|
if (!afterHook || !beforeGuard) {
|
||||||
|
throw new Error('Router hooks were not registered');
|
||||||
|
}
|
||||||
|
return { afterHook, beforeGuard };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getHooks,
|
||||||
|
removeAfterHook,
|
||||||
|
removeBeforeGuard,
|
||||||
|
router,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createScrollElement() {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.id = ELEMENT_ID_LAYOUT_SCROLL;
|
||||||
|
element.scrollTo = vi.fn();
|
||||||
|
document.body.append(element);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountLayoutScroll(router: Router) {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.append(host);
|
||||||
|
activeApp = createApp({
|
||||||
|
setup() {
|
||||||
|
useLayoutScroll(router);
|
||||||
|
return () => null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
activeApp.mount(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBeforeGuard(guard: NavigationGuard) {
|
||||||
|
await guard({} as never, {} as never, vi.fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAfterHook(hook: NavigationHookAfter, hash = '') {
|
||||||
|
await hook({ hash } as never, {} as never, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
activeApp?.unmount();
|
||||||
|
activeApp = undefined;
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
window.history.replaceState({}, '');
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useLayoutScroll', () => {
|
||||||
|
it('should scroll to top after a normal navigation', async () => {
|
||||||
|
window.history.replaceState({ position: 0 }, '');
|
||||||
|
const element = createScrollElement();
|
||||||
|
element.scrollTop = 240;
|
||||||
|
const routerMock = createRouterMock();
|
||||||
|
mountLayoutScroll(routerMock.router);
|
||||||
|
const { afterHook, beforeGuard } = routerMock.getHooks();
|
||||||
|
|
||||||
|
await runBeforeGuard(beforeGuard);
|
||||||
|
window.history.replaceState({ position: 1 }, '');
|
||||||
|
await runAfterHook(afterHook);
|
||||||
|
|
||||||
|
expect(element.scrollTo).toHaveBeenCalledWith({ top: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should scroll a hash target into view', async () => {
|
||||||
|
window.history.replaceState({ position: 0 }, '');
|
||||||
|
const element = createScrollElement();
|
||||||
|
const hashTarget = document.createElement('div');
|
||||||
|
hashTarget.id = 'section';
|
||||||
|
hashTarget.scrollIntoView = vi.fn();
|
||||||
|
element.append(hashTarget);
|
||||||
|
const routerMock = createRouterMock();
|
||||||
|
mountLayoutScroll(routerMock.router);
|
||||||
|
const { afterHook } = routerMock.getHooks();
|
||||||
|
|
||||||
|
window.history.replaceState({ position: 1 }, '');
|
||||||
|
await runAfterHook(afterHook, '#section');
|
||||||
|
|
||||||
|
expect(hashTarget.scrollIntoView).toHaveBeenCalledWith({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
});
|
||||||
|
expect(element.scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore matching hash targets outside the layout', async () => {
|
||||||
|
window.history.replaceState({ position: 0 }, '');
|
||||||
|
const hostTarget = document.createElement('div');
|
||||||
|
hostTarget.id = 'section';
|
||||||
|
hostTarget.scrollIntoView = vi.fn();
|
||||||
|
document.body.append(hostTarget);
|
||||||
|
const element = createScrollElement();
|
||||||
|
const layoutTarget = document.createElement('div');
|
||||||
|
layoutTarget.id = 'section';
|
||||||
|
layoutTarget.scrollIntoView = vi.fn();
|
||||||
|
element.append(layoutTarget);
|
||||||
|
const routerMock = createRouterMock();
|
||||||
|
mountLayoutScroll(routerMock.router);
|
||||||
|
const { afterHook } = routerMock.getHooks();
|
||||||
|
|
||||||
|
window.history.replaceState({ position: 1 }, '');
|
||||||
|
await runAfterHook(afterHook, '#section');
|
||||||
|
|
||||||
|
expect(layoutTarget.scrollIntoView).toHaveBeenCalledWith({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
});
|
||||||
|
expect(hostTarget.scrollIntoView).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should restore a saved position on history navigation', async () => {
|
||||||
|
window.history.replaceState({ position: 0 }, '');
|
||||||
|
const element = createScrollElement();
|
||||||
|
element.scrollTop = 240;
|
||||||
|
const routerMock = createRouterMock();
|
||||||
|
mountLayoutScroll(routerMock.router);
|
||||||
|
const { afterHook, beforeGuard } = routerMock.getHooks();
|
||||||
|
|
||||||
|
await runBeforeGuard(beforeGuard);
|
||||||
|
window.history.replaceState({ position: 1 }, '');
|
||||||
|
await runAfterHook(afterHook);
|
||||||
|
|
||||||
|
element.scrollTop = 80;
|
||||||
|
window.history.replaceState({ position: 0 }, '');
|
||||||
|
await runBeforeGuard(beforeGuard);
|
||||||
|
await runAfterHook(afterHook);
|
||||||
|
|
||||||
|
expect(element.scrollTo).toHaveBeenLastCalledWith({ top: 240 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove router hooks when the scope is disposed', () => {
|
||||||
|
window.history.replaceState({ position: 0 }, '');
|
||||||
|
createScrollElement();
|
||||||
|
const routerMock = createRouterMock();
|
||||||
|
mountLayoutScroll(routerMock.router);
|
||||||
|
|
||||||
|
activeApp?.unmount();
|
||||||
|
activeApp = undefined;
|
||||||
|
|
||||||
|
expect(routerMock.removeBeforeGuard).toHaveBeenCalledOnce();
|
||||||
|
expect(routerMock.removeAfterHook).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ import { cloneDeep, mapTree } from '@vben/utils';
|
|||||||
|
|
||||||
import { VbenAdminLayout } from '@vben-core/layout-ui';
|
import { VbenAdminLayout } from '@vben-core/layout-ui';
|
||||||
import { VbenBackTop, VbenLogo } from '@vben-core/shadcn-ui';
|
import { VbenBackTop, VbenLogo } from '@vben-core/shadcn-ui';
|
||||||
|
import { ELEMENT_ID_LAYOUT_SCROLL } from '@vben-core/shared/constants';
|
||||||
|
|
||||||
import { Breadcrumb, CheckUpdates, Preferences } from '../widgets';
|
import { Breadcrumb, CheckUpdates, Preferences } from '../widgets';
|
||||||
import { LayoutContent, LayoutContentSpinner } from './content';
|
import { LayoutContent, LayoutContentSpinner } from './content';
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
useMixedMenu,
|
useMixedMenu,
|
||||||
} from './menu';
|
} from './menu';
|
||||||
import { LayoutTabbar } from './tabbar';
|
import { LayoutTabbar } from './tabbar';
|
||||||
|
import { useLayoutScroll } from './use-layout-scroll';
|
||||||
|
|
||||||
defineOptions({ name: 'BasicLayout' });
|
defineOptions({ name: 'BasicLayout' });
|
||||||
|
|
||||||
@@ -69,6 +71,9 @@ const {
|
|||||||
const accessStore = useAccessStore();
|
const accessStore = useAccessStore();
|
||||||
const timezoneStore = useTimezoneStore();
|
const timezoneStore = useTimezoneStore();
|
||||||
const { refresh } = useRefresh();
|
const { refresh } = useRefresh();
|
||||||
|
const layoutScrollTarget = `#${ELEMENT_ID_LAYOUT_SCROLL}`;
|
||||||
|
|
||||||
|
useLayoutScroll();
|
||||||
|
|
||||||
const sidebarTheme = computed(() => {
|
const sidebarTheme = computed(() => {
|
||||||
const dark = isDark.value || preferences.theme.semiDarkSidebar;
|
const dark = isDark.value || preferences.theme.semiDarkSidebar;
|
||||||
@@ -468,7 +473,7 @@ const headerSlots = computed(() => {
|
|||||||
@clear-preferences-and-logout="clearPreferencesAndLogout"
|
@clear-preferences-and-logout="clearPreferencesAndLogout"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<VbenBackTop />
|
<VbenBackTop :target="layoutScrollTarget" />
|
||||||
</template>
|
</template>
|
||||||
</VbenAdminLayout>
|
</VbenAdminLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
85
packages/effects/layouts/src/basic/use-layout-scroll.ts
Normal file
85
packages/effects/layouts/src/basic/use-layout-scroll.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import type { Router } from 'vue-router';
|
||||||
|
|
||||||
|
import { nextTick, onScopeDispose } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import { getLayoutScrollElement } from '@vben-core/shared/utils';
|
||||||
|
|
||||||
|
type LayoutScrollRouter = Pick<Router, 'afterEach' | 'beforeEach'>;
|
||||||
|
|
||||||
|
function getHistoryPosition() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const position = (window.history.state as null | { position?: unknown })
|
||||||
|
?.position;
|
||||||
|
return typeof position === 'number' ? position : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHashTarget(scrollElement: HTMLElement, hash: string) {
|
||||||
|
if (!hash.startsWith('#')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = hash.slice(1);
|
||||||
|
try {
|
||||||
|
return scrollElement.querySelector<HTMLElement>(
|
||||||
|
`#${CSS.escape(decodeURIComponent(id))}`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return scrollElement.querySelector<HTMLElement>(`#${CSS.escape(id)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLayoutScroll(router: LayoutScrollRouter = useRouter()) {
|
||||||
|
const scrollPositions = new Map<number, number>();
|
||||||
|
let currentHistoryPosition = getHistoryPosition();
|
||||||
|
let isHistoryNavigation = false;
|
||||||
|
|
||||||
|
const removeBeforeGuard = router.beforeEach(() => {
|
||||||
|
const scrollElement = getLayoutScrollElement();
|
||||||
|
if (scrollElement && currentHistoryPosition !== undefined) {
|
||||||
|
scrollPositions.set(currentHistoryPosition, scrollElement.scrollTop);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextHistoryPosition = getHistoryPosition();
|
||||||
|
isHistoryNavigation =
|
||||||
|
currentHistoryPosition !== undefined &&
|
||||||
|
nextHistoryPosition !== undefined &&
|
||||||
|
currentHistoryPosition !== nextHistoryPosition;
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeAfterHook = router.afterEach(async (to, _from, failure) => {
|
||||||
|
const nextHistoryPosition = getHistoryPosition();
|
||||||
|
|
||||||
|
if (!failure) {
|
||||||
|
await nextTick();
|
||||||
|
const scrollElement = getLayoutScrollElement();
|
||||||
|
if (scrollElement) {
|
||||||
|
const savedPosition =
|
||||||
|
isHistoryNavigation && nextHistoryPosition !== undefined
|
||||||
|
? scrollPositions.get(nextHistoryPosition)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (savedPosition === undefined) {
|
||||||
|
const hashTarget = getHashTarget(scrollElement, to.hash);
|
||||||
|
if (hashTarget) {
|
||||||
|
hashTarget.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
} else {
|
||||||
|
scrollElement.scrollTo({ top: 0 });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
scrollElement.scrollTo({ top: savedPosition });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentHistoryPosition = nextHistoryPosition;
|
||||||
|
isHistoryNavigation = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
onScopeDispose(() => {
|
||||||
|
removeBeforeGuard();
|
||||||
|
removeAfterHook();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user