fix: page spinner is styled incorrectly when scrolling (#4163)
* feat: add contributor information to documents * fix: page spinner is styled incorrectly when scrolling
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
* @en_US Layout content height
|
||||
*/
|
||||
export const CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT = `--vben-content-height`;
|
||||
export const CSS_VARIABLE_LAYOUT_CONTENT_WIDTH = `--vben-content-width`;
|
||||
|
||||
/**
|
||||
* @zh_CN 默认命名空间
|
||||
|
@@ -1,140 +1,127 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getElementVisibleHeight } from './dom'; // 假设函数位于 utils.ts 中
|
||||
import { getElementVisibleRect } from './dom'; // 假设函数位于 utils.ts 中
|
||||
|
||||
describe('getElementVisibleHeight', () => {
|
||||
// Mocking the getBoundingClientRect method
|
||||
const mockGetBoundingClientRect = vi.fn();
|
||||
const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
|
||||
|
||||
beforeAll(() => {
|
||||
// Mock getBoundingClientRect method
|
||||
Element.prototype.getBoundingClientRect = mockGetBoundingClientRect;
|
||||
describe('getElementVisibleRect', () => {
|
||||
// 设置浏览器视口尺寸的 mock
|
||||
beforeEach(() => {
|
||||
vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(
|
||||
800,
|
||||
);
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800);
|
||||
vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(
|
||||
1000,
|
||||
);
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1000);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore original getBoundingClientRect method
|
||||
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
|
||||
});
|
||||
|
||||
it('should return 0 if the element is null or undefined', () => {
|
||||
expect(getElementVisibleHeight(null)).toBe(0);
|
||||
expect(getElementVisibleHeight()).toBe(0);
|
||||
});
|
||||
|
||||
it('should return the visible height of the element', () => {
|
||||
// Mock the getBoundingClientRect return value
|
||||
mockGetBoundingClientRect.mockReturnValue({
|
||||
bottom: 500,
|
||||
height: 400,
|
||||
it('should return default rect if element is undefined', () => {
|
||||
expect(getElementVisibleRect()).toEqual({
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
toJSON: () => ({}),
|
||||
top: 0,
|
||||
width: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return default rect if element is null', () => {
|
||||
expect(getElementVisibleRect(null)).toEqual({
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return correct visible rect when element is fully visible', () => {
|
||||
const element = {
|
||||
getBoundingClientRect: () => ({
|
||||
bottom: 400,
|
||||
height: 300,
|
||||
left: 200,
|
||||
right: 600,
|
||||
top: 100,
|
||||
width: 400,
|
||||
}),
|
||||
} as HTMLElement;
|
||||
|
||||
expect(getElementVisibleRect(element)).toEqual({
|
||||
bottom: 400,
|
||||
height: 300,
|
||||
left: 200,
|
||||
right: 600,
|
||||
top: 100,
|
||||
width: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 400,
|
||||
});
|
||||
|
||||
const mockElement = document.createElement('div');
|
||||
document.body.append(mockElement);
|
||||
|
||||
// Mocking window.innerHeight and document.documentElement.clientHeight
|
||||
const originalInnerHeight = window.innerHeight;
|
||||
const originalClientHeight = document.documentElement.clientHeight;
|
||||
|
||||
Object.defineProperty(window, 'innerHeight', {
|
||||
value: 600,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(document.documentElement, 'clientHeight', {
|
||||
value: 600,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
expect(getElementVisibleHeight(mockElement)).toBe(400);
|
||||
|
||||
// Restore original values
|
||||
Object.defineProperty(window, 'innerHeight', {
|
||||
value: originalInnerHeight,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(document.documentElement, 'clientHeight', {
|
||||
value: originalClientHeight,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
mockElement.remove();
|
||||
});
|
||||
|
||||
it('should return the visible height when element is partially out of viewport', () => {
|
||||
// Mock the getBoundingClientRect return value
|
||||
mockGetBoundingClientRect.mockReturnValue({
|
||||
bottom: 300,
|
||||
height: 400,
|
||||
left: 0,
|
||||
right: 0,
|
||||
toJSON: () => ({}),
|
||||
top: -100,
|
||||
width: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
it('should return correct visible rect when element is partially off-screen at the top', () => {
|
||||
const element = {
|
||||
getBoundingClientRect: () => ({
|
||||
bottom: 200,
|
||||
height: 250,
|
||||
left: 100,
|
||||
right: 500,
|
||||
top: -50,
|
||||
width: 400,
|
||||
}),
|
||||
} as HTMLElement;
|
||||
|
||||
expect(getElementVisibleRect(element)).toEqual({
|
||||
bottom: 200,
|
||||
height: 200,
|
||||
left: 100,
|
||||
right: 500,
|
||||
top: 0,
|
||||
width: 400,
|
||||
});
|
||||
|
||||
const mockElement = document.createElement('div');
|
||||
document.body.append(mockElement);
|
||||
|
||||
// Mocking window.innerHeight and document.documentElement.clientHeight
|
||||
const originalInnerHeight = window.innerHeight;
|
||||
const originalClientHeight = document.documentElement.clientHeight;
|
||||
|
||||
Object.defineProperty(window, 'innerHeight', {
|
||||
value: 600,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(document.documentElement, 'clientHeight', {
|
||||
value: 600,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
expect(getElementVisibleHeight(mockElement)).toBe(300);
|
||||
|
||||
// Restore original values
|
||||
Object.defineProperty(window, 'innerHeight', {
|
||||
value: originalInnerHeight,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(document.documentElement, 'clientHeight', {
|
||||
value: originalClientHeight,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
mockElement.remove();
|
||||
});
|
||||
|
||||
it('should return 0 if the element is completely out of viewport', () => {
|
||||
// Mock the getBoundingClientRect return value
|
||||
mockGetBoundingClientRect.mockReturnValue({
|
||||
bottom: -100,
|
||||
height: 400,
|
||||
left: 0,
|
||||
right: 0,
|
||||
toJSON: () => ({}),
|
||||
top: -500,
|
||||
width: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
it('should return correct visible rect when element is partially off-screen at the right', () => {
|
||||
const element = {
|
||||
getBoundingClientRect: () => ({
|
||||
bottom: 400,
|
||||
height: 300,
|
||||
left: 800,
|
||||
right: 1200,
|
||||
top: 100,
|
||||
width: 400,
|
||||
}),
|
||||
} as HTMLElement;
|
||||
|
||||
expect(getElementVisibleRect(element)).toEqual({
|
||||
bottom: 400,
|
||||
height: 300,
|
||||
left: 800,
|
||||
right: 1000,
|
||||
top: 100,
|
||||
width: 200,
|
||||
});
|
||||
});
|
||||
|
||||
const mockElement = document.createElement('div');
|
||||
document.body.append(mockElement);
|
||||
it('should return all zeros when element is completely off-screen', () => {
|
||||
const element = {
|
||||
getBoundingClientRect: () => ({
|
||||
bottom: 1200,
|
||||
height: 300,
|
||||
left: 1100,
|
||||
right: 1400,
|
||||
top: 900,
|
||||
width: 300,
|
||||
}),
|
||||
} as HTMLElement;
|
||||
|
||||
expect(getElementVisibleHeight(mockElement)).toBe(0);
|
||||
|
||||
mockElement.remove();
|
||||
expect(getElementVisibleRect(element)).toEqual({
|
||||
bottom: 800,
|
||||
height: 0,
|
||||
left: 1100,
|
||||
right: 1000,
|
||||
top: 900,
|
||||
width: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -1,12 +1,28 @@
|
||||
export interface VisibleDomRect {
|
||||
bottom: number;
|
||||
height: number;
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元素可见高度
|
||||
* 获取元素可见信息
|
||||
* @param element
|
||||
*/
|
||||
function getElementVisibleHeight(
|
||||
export function getElementVisibleRect(
|
||||
element?: HTMLElement | null | undefined,
|
||||
): number {
|
||||
): VisibleDomRect {
|
||||
if (!element) {
|
||||
return 0;
|
||||
return {
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
};
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
const viewHeight = Math.max(
|
||||
@@ -17,7 +33,20 @@ function getElementVisibleHeight(
|
||||
const top = Math.max(rect.top, 0);
|
||||
const bottom = Math.min(rect.bottom, viewHeight);
|
||||
|
||||
return Math.max(0, bottom - top);
|
||||
}
|
||||
const viewWidth = Math.max(
|
||||
document.documentElement.clientWidth,
|
||||
window.innerWidth,
|
||||
);
|
||||
|
||||
export { getElementVisibleHeight };
|
||||
const left = Math.max(rect.left, 0);
|
||||
const right = Math.min(rect.right, viewWidth);
|
||||
|
||||
return {
|
||||
bottom,
|
||||
height: Math.max(0, bottom - top),
|
||||
left,
|
||||
right,
|
||||
top,
|
||||
width: Math.max(0, right - left),
|
||||
};
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
export * from './use-content-height';
|
||||
export * from './use-content-style';
|
||||
export * from './use-namespace';
|
||||
export * from './use-sortable';
|
||||
export {
|
||||
|
@@ -1,47 +0,0 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT,
|
||||
getElementVisibleHeight,
|
||||
} from '@vben-core/shared';
|
||||
|
||||
import { useCssVar, useDebounceFn, useWindowSize } from '@vueuse/core';
|
||||
/**
|
||||
* @zh_CN 获取内容高度(可视区域,不包含滚动条)
|
||||
*/
|
||||
function useContentHeight() {
|
||||
const contentHeight = useCssVar(CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT);
|
||||
|
||||
const contentStyles = computed(() => {
|
||||
return {
|
||||
height: `var(${CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT})`,
|
||||
};
|
||||
});
|
||||
|
||||
return { contentHeight, contentStyles };
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN 创建内容高度监听
|
||||
*/
|
||||
function useContentHeightListener() {
|
||||
const contentElement = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const { height, width } = useWindowSize();
|
||||
const contentHeight = useCssVar(CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT);
|
||||
const debouncedCalcHeight = useDebounceFn(() => {
|
||||
contentHeight.value = `${getElementVisibleHeight(contentElement.value)}px`;
|
||||
}, 200);
|
||||
|
||||
watch([height, width], () => {
|
||||
debouncedCalcHeight();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
debouncedCalcHeight();
|
||||
});
|
||||
|
||||
return { contentElement };
|
||||
}
|
||||
|
||||
export { useContentHeight, useContentHeightListener };
|
55
packages/@core/composables/src/use-content-style.ts
Normal file
55
packages/@core/composables/src/use-content-style.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { CSSProperties } from 'vue';
|
||||
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||
|
||||
import {
|
||||
CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT,
|
||||
CSS_VARIABLE_LAYOUT_CONTENT_WIDTH,
|
||||
getElementVisibleRect,
|
||||
type VisibleDomRect,
|
||||
} from '@vben-core/shared';
|
||||
|
||||
import { useCssVar, useDebounceFn } from '@vueuse/core';
|
||||
|
||||
/**
|
||||
* @zh_CN content style
|
||||
*/
|
||||
function useContentStyle() {
|
||||
const contentElement = ref<HTMLDivElement | null>(null);
|
||||
const visibleDomRect = ref<null | VisibleDomRect>(null);
|
||||
const contentHeight = useCssVar(CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT);
|
||||
const contentWidth = useCssVar(CSS_VARIABLE_LAYOUT_CONTENT_WIDTH);
|
||||
|
||||
const overlayStyle = computed((): CSSProperties => {
|
||||
const { height, left, top, width } = visibleDomRect.value ?? {};
|
||||
return {
|
||||
height: `${height}px`,
|
||||
left: `${left}px`,
|
||||
position: 'fixed',
|
||||
top: `${top}px`,
|
||||
width: `${width}px`,
|
||||
zIndex: 1000,
|
||||
};
|
||||
});
|
||||
|
||||
const debouncedCalcHeight = useDebounceFn(
|
||||
(_entries: ResizeObserverEntry[]) => {
|
||||
visibleDomRect.value = getElementVisibleRect(contentElement.value);
|
||||
contentHeight.value = `${visibleDomRect.value.height}px`;
|
||||
contentWidth.value = `${visibleDomRect.value.width}px`;
|
||||
},
|
||||
100,
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if (contentElement.value) {
|
||||
const observer = new ResizeObserver(debouncedCalcHeight);
|
||||
observer.observe(contentElement.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { contentElement, overlayStyle, visibleDomRect };
|
||||
}
|
||||
|
||||
export { useContentStyle };
|
@@ -4,7 +4,7 @@ import type { ContentCompactType } from '@vben-core/typings';
|
||||
import type { CSSProperties } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useContentHeightListener } from '@vben-core/composables';
|
||||
import { useContentStyle } from '@vben-core/composables';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -24,7 +24,7 @@ interface Props {
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const { contentElement } = useContentHeightListener();
|
||||
const { contentElement, overlayStyle } = useContentStyle();
|
||||
|
||||
const style = computed((): CSSProperties => {
|
||||
const {
|
||||
@@ -53,7 +53,9 @@ const style = computed((): CSSProperties => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main ref="contentElement" :style="style" class="bg-background-deep">
|
||||
<main ref="contentElement" :style="style" class="bg-background-deep relative">
|
||||
<!-- <BlurShadow :style="shadowStyle" /> -->
|
||||
<slot :overlay-style="overlayStyle" name="overlay"></slot>
|
||||
<slot></slot>
|
||||
</main>
|
||||
</template>
|
||||
|
@@ -519,6 +519,10 @@ function handleOpenMenu() {
|
||||
class="transition-[margin-top] duration-200"
|
||||
>
|
||||
<slot name="content"></slot>
|
||||
|
||||
<template #overlay="{ overlayStyle }">
|
||||
<slot :overlay-style="overlayStyle" name="content-overlay"></slot>
|
||||
</template>
|
||||
</LayoutContent>
|
||||
|
||||
<LayoutFooter
|
||||
|
@@ -0,0 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { VbenSpinner } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { useContentSpinner } from './use-content-spinner';
|
||||
|
||||
defineOptions({ name: 'LayoutContentSpinner' });
|
||||
|
||||
defineProps<{ overlayStyle: CSSProperties }>();
|
||||
|
||||
const { spinning } = useContentSpinner();
|
||||
</script>
|
||||
<template>
|
||||
<VbenSpinner :spinning="spinning" :style="overlayStyle" />
|
||||
</template>
|
@@ -7,20 +7,15 @@ import type {
|
||||
import { type VNode } from 'vue';
|
||||
import { RouterView } from 'vue-router';
|
||||
|
||||
import { useContentHeight } from '@vben/hooks';
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
import { storeToRefs, useTabbarStore } from '@vben/stores';
|
||||
import { VbenSpinner } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { IFrameRouterView } from '../../iframe';
|
||||
import { useContentSpinner } from './use-content-spinner';
|
||||
|
||||
defineOptions({ name: 'LayoutContent' });
|
||||
|
||||
const tabbarStore = useTabbarStore();
|
||||
const { keepAlive } = usePreferences();
|
||||
const { spinning } = useContentSpinner();
|
||||
const { contentStyles } = useContentHeight();
|
||||
|
||||
const { getCachedTabs, getExcludeCachedTabs, renderRouteView } =
|
||||
storeToRefs(tabbarStore);
|
||||
@@ -86,11 +81,6 @@ function transformComponent(
|
||||
|
||||
<template>
|
||||
<div class="relative h-full">
|
||||
<VbenSpinner
|
||||
v-if="preferences.transition.loading"
|
||||
:spinning="spinning"
|
||||
:style="contentStyles"
|
||||
/>
|
||||
<IFrameRouterView />
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition :name="getTransitionName(route)" appear mode="out-in">
|
||||
|
@@ -1 +1,2 @@
|
||||
export { default as LayoutContent } from './content.vue';
|
||||
export { default as LayoutContentSpinner } from './content-spinner.vue';
|
||||
|
@@ -16,7 +16,7 @@ import { VbenAdminLayout } from '@vben-core/layout-ui';
|
||||
import { Toaster, VbenBackTop, VbenLogo } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { Breadcrumb, CheckUpdates, Preferences } from '../widgets';
|
||||
import { LayoutContent } from './content';
|
||||
import { LayoutContent, LayoutContentSpinner } from './content';
|
||||
import { Copyright } from './copyright';
|
||||
import { LayoutFooter } from './footer';
|
||||
import { LayoutHeader } from './header';
|
||||
@@ -297,6 +297,12 @@ const headerSlots = computed(() => {
|
||||
<template #content>
|
||||
<LayoutContent />
|
||||
</template>
|
||||
<template
|
||||
v-if="preferences.transition.loading"
|
||||
#content-overlay="{ overlayStyle }"
|
||||
>
|
||||
<LayoutContentSpinner :overlay-style="overlayStyle" />
|
||||
</template>
|
||||
|
||||
<!-- 页脚 -->
|
||||
<template v-if="preferences.footer.enable" #footer>
|
||||
|
Reference in New Issue
Block a user