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:
Vben
2024-08-15 21:48:52 +08:00
committed by GitHub
parent 11551903f0
commit debb32d353
21 changed files with 554 additions and 263 deletions

View File

@@ -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 默认命名空间

View File

@@ -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,
});
});
});

View File

@@ -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),
};
}

View File

@@ -1,4 +1,4 @@
export * from './use-content-height';
export * from './use-content-style';
export * from './use-namespace';
export * from './use-sortable';
export {

View File

@@ -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 };

View 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 };

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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">

View File

@@ -1 +1,2 @@
export { default as LayoutContent } from './content.vue';
export { default as LayoutContentSpinner } from './content-spinner.vue';

View File

@@ -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>