fix(@vben-core/layout-ui): stabilize internal layout scrolling

This commit is contained in:
Dream
2026-07-24 16:57:32 +08:00
parent 9a5d052fa5
commit aaf0da582c
5 changed files with 304 additions and 151 deletions

View File

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

View File

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

View File

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

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

View File

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