gengx
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as LayoutContent } from './layout-content.vue';
|
||||
export { default as LayoutFooter } from './layout-footer.vue';
|
||||
export { default as LayoutHeader } from './layout-header.vue';
|
||||
export { default as LayoutSidebar } from './layout-sidebar.vue';
|
||||
export { default as LayoutTabbar } from './layout-tabbar.vue';
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import type { ContentCompactType } from '@vben-core/typings';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 内容区域定宽
|
||||
*/
|
||||
contentCompact: ContentCompactType;
|
||||
/**
|
||||
* 定宽布局宽度
|
||||
*/
|
||||
contentCompactWidth: number;
|
||||
padding: number;
|
||||
paddingBottom: number;
|
||||
paddingLeft: number;
|
||||
paddingRight: number;
|
||||
paddingTop: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const overlayViewportStyle: CSSProperties = {
|
||||
height:
|
||||
'calc(var(--vben-viewport-height) - var(--vben-header-height, 0px) - var(--vben-footer-height, 0px))',
|
||||
};
|
||||
|
||||
const style = computed((): CSSProperties => {
|
||||
const {
|
||||
contentCompact,
|
||||
padding,
|
||||
paddingBottom,
|
||||
paddingLeft,
|
||||
paddingRight,
|
||||
paddingTop,
|
||||
} = props;
|
||||
|
||||
const compactStyle: CSSProperties =
|
||||
contentCompact === 'compact'
|
||||
? { margin: '0 auto', width: `${props.contentCompactWidth}px` }
|
||||
: {};
|
||||
return {
|
||||
...compactStyle,
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
padding: `${padding}px`,
|
||||
paddingBottom: `${paddingBottom}px`,
|
||||
paddingLeft: `${paddingLeft}px`,
|
||||
paddingRight: `${paddingRight}px`,
|
||||
paddingTop: `${paddingTop}px`,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main :style="style" class="relative min-h-0 min-w-0">
|
||||
<div
|
||||
v-if="$slots.overlay"
|
||||
data-layout-region="content-overlay"
|
||||
class="pointer-events-none sticky top-0 z-150 h-0 w-full"
|
||||
>
|
||||
<div
|
||||
:style="overlayViewportStyle"
|
||||
data-layout-region="overlay-viewport"
|
||||
class="pointer-events-none relative min-h-0 w-full"
|
||||
>
|
||||
<slot name="overlay"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 是否固定在底部
|
||||
*/
|
||||
fixed?: boolean;
|
||||
height: number;
|
||||
/**
|
||||
* 是否显示
|
||||
* @default true
|
||||
*/
|
||||
show?: boolean;
|
||||
width: string;
|
||||
zIndex: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
show: true,
|
||||
});
|
||||
|
||||
const style = computed((): CSSProperties => {
|
||||
const { fixed, height, show, width, zIndex } = props;
|
||||
return {
|
||||
height: `${height}px`,
|
||||
marginBottom: show ? '0' : `-${height}px`,
|
||||
position: fixed ? 'fixed' : 'static',
|
||||
transform: show ? 'translateY(0)' : 'translateY(100%)',
|
||||
width,
|
||||
zIndex,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer
|
||||
:style="style"
|
||||
class="bottom-0 w-full shrink-0 bg-background-deep transition-all duration-200"
|
||||
>
|
||||
<slot></slot>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed, useSlots } from 'vue';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 横屏
|
||||
*/
|
||||
fullWidth: boolean;
|
||||
/**
|
||||
* 高度
|
||||
*/
|
||||
height: number;
|
||||
/**
|
||||
* 是否移动端
|
||||
*/
|
||||
isMobile: boolean;
|
||||
/**
|
||||
* logo是否显示
|
||||
*/
|
||||
logoVisible?: boolean;
|
||||
/**
|
||||
* 是否显示
|
||||
*/
|
||||
show: boolean;
|
||||
/**
|
||||
* 侧边菜单宽度
|
||||
*/
|
||||
sidebarWidth: number;
|
||||
/**
|
||||
* 主题
|
||||
*/
|
||||
theme: string | undefined;
|
||||
/**
|
||||
* 宽度
|
||||
*/
|
||||
width: string;
|
||||
/**
|
||||
* zIndex
|
||||
*/
|
||||
zIndex: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
logoVisible: true,
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
const style = computed((): CSSProperties => {
|
||||
const { fullWidth, height, show } = props;
|
||||
const right = !show || !fullWidth ? undefined : 0;
|
||||
|
||||
return {
|
||||
height: `${height}px`,
|
||||
marginTop: show ? 0 : `-${height}px`,
|
||||
right,
|
||||
};
|
||||
});
|
||||
|
||||
const logoStyle = computed((): CSSProperties => {
|
||||
if (!props.logoVisible) {
|
||||
return {
|
||||
minWidth: '12px',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
minWidth: `${props.isMobile ? 40 : props.sidebarWidth}px`,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
:class="theme"
|
||||
:style="style"
|
||||
class="top-0 flex w-full flex-[0_0_auto] items-center border-b border-border bg-header pl-2 transition-[margin-top] duration-200"
|
||||
>
|
||||
<div v-if="slots.logo || (!logoVisible && !isMobile)" :style="logoStyle">
|
||||
<slot name="logo"></slot>
|
||||
</div>
|
||||
|
||||
<slot name="toggle-button"> </slot>
|
||||
|
||||
<slot></slot>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,467 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed, onUnmounted, shallowRef, useSlots, watchEffect } from 'vue';
|
||||
|
||||
import { useScrollLock } from '@vben-core/composables';
|
||||
import { VbenScrollbar } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { useSidebarDrag } from '../hooks/use-sidebar-drag';
|
||||
import { SidebarCollapseButton, SidebarFixedButton } from './widgets';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 折叠区域高度
|
||||
* @default 42
|
||||
*/
|
||||
collapseHeight?: number;
|
||||
/**
|
||||
* 折叠宽度
|
||||
* @default 48
|
||||
*/
|
||||
collapseWidth?: number;
|
||||
/**
|
||||
* 隐藏的dom是否可见
|
||||
* @default true
|
||||
*/
|
||||
domVisible?: boolean;
|
||||
/**
|
||||
* 标准侧栏展开宽度
|
||||
*/
|
||||
expandedWidth?: number;
|
||||
/**
|
||||
* 扩展区域extra-title的高度
|
||||
*/
|
||||
extraTitleHeight?: number;
|
||||
/**
|
||||
* 扩展区域宽度
|
||||
*/
|
||||
extraWidth: number;
|
||||
/**
|
||||
* 固定扩展区域
|
||||
* @default false
|
||||
*/
|
||||
fixedExtra?: boolean;
|
||||
/**
|
||||
* 头部高度
|
||||
*/
|
||||
headerHeight: number;
|
||||
/**
|
||||
* 是否移动端抽屉模式
|
||||
* @default false
|
||||
*/
|
||||
isMobile?: boolean;
|
||||
/**
|
||||
* 是否侧边混合模式
|
||||
* @default false
|
||||
*/
|
||||
isSidebarMixed?: boolean;
|
||||
/**
|
||||
* 顶部margin
|
||||
* @default 60
|
||||
*/
|
||||
marginTop?: number;
|
||||
/**
|
||||
* 混合菜单宽度
|
||||
* @default 80
|
||||
*/
|
||||
mixedWidth?: number;
|
||||
/**
|
||||
* 顶部padding
|
||||
* @default 60
|
||||
*/
|
||||
paddingTop?: number;
|
||||
/**
|
||||
* 是否显示
|
||||
* @default true
|
||||
*/
|
||||
show?: boolean;
|
||||
/**
|
||||
* 显示折叠按钮
|
||||
* @default true
|
||||
*/
|
||||
showCollapseButton?: boolean;
|
||||
/**
|
||||
* 显示固定按钮
|
||||
* @default true
|
||||
*/
|
||||
showFixedButton?: boolean;
|
||||
/**
|
||||
* 主题
|
||||
*/
|
||||
theme: string;
|
||||
/**
|
||||
* 子主题
|
||||
*/
|
||||
themeSub: string;
|
||||
/**
|
||||
* 宽度
|
||||
*/
|
||||
width: number;
|
||||
/**
|
||||
* zIndex
|
||||
* @default 0
|
||||
*/
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
collapseHeight: 42,
|
||||
collapseWidth: 48,
|
||||
domVisible: true,
|
||||
expandedWidth: 180,
|
||||
extraTitleHeight: undefined,
|
||||
fixedExtra: false,
|
||||
isMobile: false,
|
||||
isSidebarMixed: false,
|
||||
marginTop: 0,
|
||||
mixedWidth: 70,
|
||||
paddingTop: 0,
|
||||
show: true,
|
||||
showCollapseButton: true,
|
||||
showFixedButton: true,
|
||||
zIndex: 0,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ leave: []; 'update:width': [value: number] }>();
|
||||
const draggable = defineModel<boolean>('draggable');
|
||||
const collapse = defineModel<boolean>('collapse');
|
||||
const extraCollapse = defineModel<boolean>('extraCollapse');
|
||||
const expandOnHovering = defineModel<boolean>('expandOnHovering');
|
||||
const expandOnHover = defineModel<boolean>('expandOnHover');
|
||||
const extraVisible = defineModel<boolean>('extraVisible');
|
||||
|
||||
const isLocked = useScrollLock({ immediate: false });
|
||||
const slots = useSlots();
|
||||
|
||||
const asideRef = shallowRef<HTMLElement | null>(null);
|
||||
const dragBarRef = shallowRef<HTMLElement | null>(null);
|
||||
|
||||
const hiddenSideStyle = computed((): CSSProperties => {
|
||||
const widthValue = props.show ? getMenuWidthValue(true) : '0px';
|
||||
return {
|
||||
flexBasis: widthValue,
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
};
|
||||
});
|
||||
|
||||
const sidebarVisualWidth = computed(() => {
|
||||
const currentWidth = Number.parseFloat(getMenuWidthValue(false));
|
||||
return !props.isMobile && !props.isSidebarMixed
|
||||
? Math.max(currentWidth, props.expandedWidth)
|
||||
: currentWidth;
|
||||
});
|
||||
|
||||
const dragBarStyle = computed((): CSSProperties => {
|
||||
const currentWidth = Number.parseFloat(getMenuWidthValue(false));
|
||||
return {
|
||||
right: `${Math.max(0, sidebarVisualWidth.value - currentWidth)}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const style = computed((): CSSProperties => {
|
||||
const { isSidebarMixed, marginTop, paddingTop, zIndex } = props;
|
||||
|
||||
return {
|
||||
'--scroll-shadow': 'var(--sidebar)',
|
||||
...calcMenuWidthStyle(),
|
||||
height: `calc(100% - ${marginTop}px)`,
|
||||
marginTop: `${marginTop}px`,
|
||||
paddingTop: `${paddingTop}px`,
|
||||
zIndex,
|
||||
...(isSidebarMixed && extraVisible.value ? { transition: 'none' } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
const extraStyle = computed((): CSSProperties => {
|
||||
const { extraWidth, show, width, zIndex } = props;
|
||||
|
||||
return {
|
||||
left: `${width}px`,
|
||||
width: extraVisible.value && show ? `${extraWidth}px` : 0,
|
||||
zIndex,
|
||||
};
|
||||
});
|
||||
|
||||
const extraTitleStyle = computed((): CSSProperties => {
|
||||
const { extraTitleHeight, headerHeight } = props;
|
||||
|
||||
return {
|
||||
height: `${extraTitleHeight ?? headerHeight - 1}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const contentWidthStyle = computed((): CSSProperties => {
|
||||
const { fixedExtra, isSidebarMixed, mixedWidth } = props;
|
||||
if (isSidebarMixed && fixedExtra) {
|
||||
return { width: `${mixedWidth}px` };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const contentStyle = computed((): CSSProperties => {
|
||||
const { collapseHeight, headerHeight } = props;
|
||||
|
||||
return {
|
||||
height: `calc(100% - ${headerHeight + collapseHeight}px)`,
|
||||
paddingTop: '8px',
|
||||
...contentWidthStyle.value,
|
||||
};
|
||||
});
|
||||
|
||||
const headerStyle = computed((): CSSProperties => {
|
||||
const { headerHeight, isSidebarMixed } = props;
|
||||
|
||||
return {
|
||||
...(isSidebarMixed ? { display: 'flex', justifyContent: 'center' } : {}),
|
||||
height: `${headerHeight - 1}px`,
|
||||
...contentWidthStyle.value,
|
||||
};
|
||||
});
|
||||
|
||||
const extraContentStyle = computed((): CSSProperties => {
|
||||
const { collapseHeight, extraTitleHeight, headerHeight } = props;
|
||||
const titleHeight = extraTitleHeight ?? headerHeight;
|
||||
return {
|
||||
height: `calc(100% - ${titleHeight + collapseHeight}px)`,
|
||||
};
|
||||
});
|
||||
|
||||
const collapseStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
height: `${props.collapseHeight}px`,
|
||||
};
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
extraVisible.value = props.fixedExtra ? true : extraVisible.value;
|
||||
});
|
||||
|
||||
function getMenuWidthValue(isHiddenDom: boolean) {
|
||||
const {
|
||||
collapseWidth,
|
||||
extraWidth,
|
||||
mixedWidth,
|
||||
fixedExtra,
|
||||
isSidebarMixed,
|
||||
width,
|
||||
} = props;
|
||||
|
||||
let widthValue =
|
||||
width === 0
|
||||
? '0px'
|
||||
: `${width + (isSidebarMixed && fixedExtra && extraVisible.value ? extraWidth : 0)}px`;
|
||||
|
||||
if (isHiddenDom && expandOnHovering.value && !expandOnHover.value) {
|
||||
widthValue = isSidebarMixed ? `${mixedWidth}px` : `${collapseWidth}px`;
|
||||
}
|
||||
return widthValue;
|
||||
}
|
||||
|
||||
function calcMenuWidthStyle(): CSSProperties {
|
||||
const widthValue = getMenuWidthValue(false);
|
||||
const currentWidth = Number.parseFloat(widthValue);
|
||||
const clippedWidth = Math.max(0, sidebarVisualWidth.value - currentWidth);
|
||||
let transform: CSSProperties['transform'];
|
||||
|
||||
if (props.isMobile) {
|
||||
transform = undefined;
|
||||
} else if (props.show) {
|
||||
transform = 'translate3d(0, 0, 0)';
|
||||
} else {
|
||||
transform = 'translate3d(-100%, 0, 0)';
|
||||
}
|
||||
|
||||
return {
|
||||
...(widthValue === '0px' ? { overflow: 'hidden' } : {}),
|
||||
clipPath: `inset(0 ${clippedWidth}px 0 0)`,
|
||||
transform,
|
||||
width: `${sidebarVisualWidth.value}px`,
|
||||
};
|
||||
}
|
||||
|
||||
function handleMouseenter(e: MouseEvent) {
|
||||
if (e?.offsetX < 10) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 未开启和未折叠状态不生效
|
||||
if (expandOnHover.value) {
|
||||
return;
|
||||
}
|
||||
if (!expandOnHovering.value) {
|
||||
collapse.value = false;
|
||||
}
|
||||
if (props.isSidebarMixed) {
|
||||
isLocked.value = true;
|
||||
}
|
||||
expandOnHovering.value = true;
|
||||
}
|
||||
|
||||
function handleMouseleave() {
|
||||
emit('leave');
|
||||
if (props.isSidebarMixed) {
|
||||
isLocked.value = false;
|
||||
}
|
||||
if (expandOnHover.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
expandOnHovering.value = false;
|
||||
collapse.value = true;
|
||||
extraVisible.value = false;
|
||||
}
|
||||
|
||||
const { startDrag, endDrag } = useSidebarDrag();
|
||||
|
||||
const handleDragSidebar = (e: MouseEvent) => {
|
||||
const { isSidebarMixed, collapseWidth, width } = props;
|
||||
const minLimit = isSidebarMixed ? width + collapseWidth : collapseWidth;
|
||||
const maxLimit = isSidebarMixed ? width + 320 : 320;
|
||||
|
||||
startDrag(
|
||||
e,
|
||||
{
|
||||
min: minLimit,
|
||||
max: maxLimit,
|
||||
},
|
||||
{
|
||||
target: asideRef.value,
|
||||
dragBar: dragBarRef.value,
|
||||
},
|
||||
(newWidth) => {
|
||||
if (isSidebarMixed) {
|
||||
emit('update:width', newWidth - width);
|
||||
extraCollapse.value = collapse.value =
|
||||
newWidth - width <= collapseWidth;
|
||||
} else {
|
||||
emit('update:width', newWidth);
|
||||
collapse.value = extraCollapse.value = newWidth <= collapseWidth;
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
endDrag();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="domVisible"
|
||||
:class="theme"
|
||||
:style="hiddenSideStyle"
|
||||
class="h-full"
|
||||
></div>
|
||||
<Transition name="mobile-sidebar">
|
||||
<aside
|
||||
v-if="!isMobile || !collapse"
|
||||
ref="asideRef"
|
||||
data-layout-region="sidebar"
|
||||
:inert="!show || width === 0"
|
||||
:style="style"
|
||||
class="fixed left-0 top-0 h-full"
|
||||
:class="[
|
||||
theme,
|
||||
{
|
||||
'border-r border-border bg-sidebar transition-[clip-path,transform] duration-300 ease-out':
|
||||
!isMobile && !isSidebarMixed,
|
||||
'transition-transform duration-300 ease-out':
|
||||
!isMobile && isSidebarMixed,
|
||||
},
|
||||
]"
|
||||
@mouseenter="handleMouseenter"
|
||||
@mouseleave="handleMouseleave"
|
||||
>
|
||||
<div
|
||||
class="h-full"
|
||||
:class="[
|
||||
{
|
||||
'bg-sidebar-deep': isSidebarMixed,
|
||||
'border-r border-border bg-sidebar': !isSidebarMixed,
|
||||
},
|
||||
]"
|
||||
:style="{ width: `${width}px` }"
|
||||
>
|
||||
<SidebarFixedButton
|
||||
v-if="!collapse && !isSidebarMixed && showFixedButton"
|
||||
v-model:expand-on-hover="expandOnHover"
|
||||
/>
|
||||
<div v-if="slots.logo" :style="headerStyle">
|
||||
<slot name="logo"></slot>
|
||||
</div>
|
||||
<VbenScrollbar :style="contentStyle" shadow shadow-border>
|
||||
<slot></slot>
|
||||
</VbenScrollbar>
|
||||
|
||||
<div :style="collapseStyle"></div>
|
||||
<SidebarCollapseButton
|
||||
v-if="showCollapseButton && !isSidebarMixed"
|
||||
v-model:collapsed="collapse"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isSidebarMixed"
|
||||
:class="[
|
||||
themeSub,
|
||||
{
|
||||
'border-l': extraVisible,
|
||||
},
|
||||
]"
|
||||
:style="extraStyle"
|
||||
class="fixed top-0 h-full overflow-hidden border-r border-border bg-sidebar transition-[left,width] duration-300 ease-out"
|
||||
>
|
||||
<SidebarCollapseButton
|
||||
v-if="isSidebarMixed && expandOnHover"
|
||||
v-model:collapsed="extraCollapse"
|
||||
/>
|
||||
|
||||
<SidebarFixedButton
|
||||
v-if="!extraCollapse"
|
||||
v-model:expand-on-hover="expandOnHover"
|
||||
/>
|
||||
<div v-if="!extraCollapse" :style="extraTitleStyle" class="pl-2">
|
||||
<slot name="extra-title"></slot>
|
||||
</div>
|
||||
<VbenScrollbar
|
||||
:style="extraContentStyle"
|
||||
class="border-border py-2"
|
||||
shadow
|
||||
shadow-border
|
||||
>
|
||||
<slot name="extra"></slot>
|
||||
</VbenScrollbar>
|
||||
</div>
|
||||
<div
|
||||
v-if="draggable"
|
||||
ref="dragBarRef"
|
||||
:style="dragBarStyle"
|
||||
class="absolute inset-y-0 -right-px z-1000 w-0.5 cursor-col-resize hover:bg-primary"
|
||||
@mousedown="handleDragSidebar"
|
||||
></div>
|
||||
</aside>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mobile-sidebar-enter-active,
|
||||
.mobile-sidebar-leave-active {
|
||||
transition: transform 300ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.mobile-sidebar-enter-from,
|
||||
.mobile-sidebar-leave-to {
|
||||
transform: translate3d(-100%, 0, 0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mobile-sidebar-enter-active,
|
||||
.mobile-sidebar-leave-active {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 高度
|
||||
*/
|
||||
height: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const style = computed((): CSSProperties => {
|
||||
const { height } = props;
|
||||
return {
|
||||
height: `${height}px`,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:style="style"
|
||||
class="flex w-full border-b border-border bg-background transition-colors duration-200"
|
||||
>
|
||||
<slot></slot>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as SidebarCollapseButton } from './sidebar-collapse-button.vue';
|
||||
export { default as SidebarFixedButton } from './sidebar-fixed-button.vue';
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { useSimpleLocale } from '@vben-core/composables';
|
||||
import { ChevronsLeft, ChevronsRight } from '@vben-core/icons';
|
||||
|
||||
const collapsed = defineModel<boolean>('collapsed');
|
||||
const { $t } = useSimpleLocale();
|
||||
|
||||
function handleCollapsed() {
|
||||
collapsed.value = !collapsed.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="$t('toggleSidebar')"
|
||||
:aria-pressed="collapsed"
|
||||
data-layout-action="toggle-sidebar-collapse"
|
||||
class="absolute bottom-2 left-3 z-10 flex-center cursor-pointer rounded-sm bg-accent p-1 text-foreground/60 hover:bg-accent-hover hover:text-foreground"
|
||||
@click.stop="handleCollapsed"
|
||||
>
|
||||
<ChevronsRight v-if="collapsed" class="size-4" />
|
||||
<ChevronsLeft v-else class="size-4" />
|
||||
</button>
|
||||
</template>
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { Pin, PinOff } from '@vben-core/icons';
|
||||
|
||||
const expandOnHover = defineModel<boolean>('expandOnHover');
|
||||
|
||||
function toggleFixed() {
|
||||
expandOnHover.value = !expandOnHover.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute right-3 bottom-2 z-10 flex-center cursor-pointer rounded-sm bg-accent p-1.25 text-foreground/60 transition-all duration-300 hover:bg-accent-hover hover:text-foreground"
|
||||
@click="toggleFixed"
|
||||
>
|
||||
<PinOff v-if="!expandOnHover" class="size-3.5" />
|
||||
<Pin v-else class="size-3.5" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { LayoutType } from '@vben-core/typings';
|
||||
|
||||
import type { VbenLayoutProps } from '../vben-layout';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
export function useLayout(props: VbenLayoutProps) {
|
||||
const currentLayout = computed(() =>
|
||||
props.isMobile ? 'sidebar-nav' : (props.layout as LayoutType),
|
||||
);
|
||||
|
||||
/**
|
||||
* 是否全屏显示content,不需要侧边、底部、顶部、tab区域
|
||||
*/
|
||||
const isFullContent = computed(() => currentLayout.value === 'full-content');
|
||||
|
||||
/**
|
||||
* 是否侧边混合模式
|
||||
*/
|
||||
const isSidebarMixedNav = computed(
|
||||
() => currentLayout.value === 'sidebar-mixed-nav',
|
||||
);
|
||||
|
||||
/**
|
||||
* 是否为头部导航模式
|
||||
*/
|
||||
const isHeaderNav = computed(() => currentLayout.value === 'header-nav');
|
||||
|
||||
/**
|
||||
* 是否为混合导航模式
|
||||
*/
|
||||
const isMixedNav = computed(
|
||||
() =>
|
||||
currentLayout.value === 'mixed-nav' ||
|
||||
currentLayout.value === 'header-sidebar-nav',
|
||||
);
|
||||
|
||||
/**
|
||||
* 是否为头部混合模式
|
||||
*/
|
||||
const isHeaderMixedNav = computed(
|
||||
() => currentLayout.value === 'header-mixed-nav',
|
||||
);
|
||||
|
||||
return {
|
||||
currentLayout,
|
||||
isFullContent,
|
||||
isHeaderMixedNav,
|
||||
isHeaderNav,
|
||||
isMixedNav,
|
||||
isSidebarMixedNav,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
interface DragOptions {
|
||||
max: number;
|
||||
min: number;
|
||||
}
|
||||
|
||||
interface DragElements {
|
||||
dragBar: HTMLElement | null;
|
||||
target: HTMLElement | null;
|
||||
}
|
||||
|
||||
type DragCallback = (newWidth: number) => void;
|
||||
|
||||
export function useSidebarDrag() {
|
||||
const isDragging = ref(false);
|
||||
let cleanup: (() => void) | null = null;
|
||||
let dragOverlay: HTMLElement | null = null;
|
||||
|
||||
const startDrag = (
|
||||
e: MouseEvent,
|
||||
options: DragOptions,
|
||||
elements: DragElements,
|
||||
onDrag: DragCallback,
|
||||
) => {
|
||||
const { min, max } = options;
|
||||
const { dragBar, target } = elements;
|
||||
|
||||
if (isDragging.value || !dragBar || !target) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
isDragging.value = true;
|
||||
|
||||
const startX = e.clientX;
|
||||
const startWidth = target.getBoundingClientRect().width;
|
||||
const startLeft = dragBar.offsetLeft;
|
||||
|
||||
dragBar.classList.add('bg-primary');
|
||||
dragBar.classList.remove('bg-primary/30');
|
||||
|
||||
const dragBarTransition = dragBar.style.transition;
|
||||
const targetTransition = target.style.transition;
|
||||
|
||||
dragBar.style.transition = 'none';
|
||||
target.style.transition = 'none';
|
||||
|
||||
dragOverlay = document.createElement('div');
|
||||
dragOverlay.style.position = 'fixed';
|
||||
dragOverlay.style.inset = '0';
|
||||
dragOverlay.style.zIndex = '9999';
|
||||
dragOverlay.style.cursor = 'col-resize';
|
||||
dragOverlay.style.userSelect = 'none';
|
||||
dragOverlay.style.outline = 'none';
|
||||
dragOverlay.tabIndex = -1;
|
||||
dragOverlay.style.background = 'rgba(0,0,0,0)';
|
||||
document.body.append(dragOverlay);
|
||||
|
||||
const onMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (!isDragging.value || !dragBar || !target) {
|
||||
endDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = moveEvent.clientX - startX;
|
||||
let currentWidth = startWidth + deltaX;
|
||||
|
||||
const isOutOfMin = currentWidth < min;
|
||||
const isOutOfMax = currentWidth > max;
|
||||
const isOutOfBounds = isOutOfMin || isOutOfMax;
|
||||
|
||||
if (isOutOfMin) currentWidth = min;
|
||||
if (isOutOfMax) currentWidth = max;
|
||||
|
||||
const newLeft = startLeft + (currentWidth - startWidth);
|
||||
|
||||
if (dragOverlay)
|
||||
dragOverlay.style.cursor = isOutOfBounds ? 'not-allowed' : 'col-resize';
|
||||
|
||||
dragBar.style.left = `${newLeft}px`;
|
||||
|
||||
if (isOutOfBounds) {
|
||||
dragBar.classList.add('bg-primary/30');
|
||||
dragBar.classList.remove('bg-primary');
|
||||
} else {
|
||||
dragBar.classList.add('bg-primary');
|
||||
dragBar.classList.remove('bg-primary/30');
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = (upEvent: MouseEvent) => {
|
||||
if (!isDragging.value || !dragBar || !target) {
|
||||
endDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = upEvent.clientX - startX;
|
||||
let newWidth = startWidth + deltaX;
|
||||
|
||||
newWidth = Math.min(max, Math.max(min, newWidth));
|
||||
|
||||
dragBar.classList.remove('bg-primary', 'bg-primary/30');
|
||||
|
||||
try {
|
||||
onDrag?.(Math.round(newWidth));
|
||||
} finally {
|
||||
endDrag();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
|
||||
cleanup = () => {
|
||||
if (!cleanup) return;
|
||||
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
|
||||
if (dragBar) {
|
||||
dragBar.style.transition = dragBarTransition;
|
||||
dragBar.style.left = '';
|
||||
dragBar.classList.remove('bg-primary', 'bg-primary/30');
|
||||
}
|
||||
|
||||
if (target) {
|
||||
target.style.transition = targetTransition;
|
||||
}
|
||||
|
||||
if (dragOverlay) {
|
||||
dragOverlay.remove();
|
||||
dragOverlay = null;
|
||||
}
|
||||
|
||||
isDragging.value = false;
|
||||
cleanup = null;
|
||||
};
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
cleanup?.();
|
||||
};
|
||||
|
||||
return {
|
||||
startDrag,
|
||||
endDrag,
|
||||
get isDragging() {
|
||||
return isDragging.value;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type * from './vben-layout';
|
||||
export { default as VbenAdminLayout } from './vben-layout.vue';
|
||||
@@ -0,0 +1,188 @@
|
||||
import type {
|
||||
ContentCompactType,
|
||||
LayoutHeaderModeType,
|
||||
LayoutType,
|
||||
ThemeModeType,
|
||||
} from '@vben-core/typings';
|
||||
|
||||
interface VbenLayoutProps {
|
||||
/**
|
||||
* 内容区域定宽
|
||||
* @default 'wide'
|
||||
*/
|
||||
contentCompact?: ContentCompactType;
|
||||
/**
|
||||
* 定宽布局宽度
|
||||
* @default 1200
|
||||
*/
|
||||
contentCompactWidth?: number;
|
||||
/**
|
||||
* padding
|
||||
* @default 16
|
||||
*/
|
||||
contentPadding?: number;
|
||||
/**
|
||||
* paddingBottom
|
||||
* @default 16
|
||||
*/
|
||||
contentPaddingBottom?: number;
|
||||
/**
|
||||
* paddingLeft
|
||||
* @default 16
|
||||
*/
|
||||
contentPaddingLeft?: number;
|
||||
/**
|
||||
* paddingRight
|
||||
* @default 16
|
||||
*/
|
||||
contentPaddingRight?: number;
|
||||
/**
|
||||
* paddingTop
|
||||
* @default 16
|
||||
*/
|
||||
contentPaddingTop?: number;
|
||||
/**
|
||||
* footer 是否可见
|
||||
* @default false
|
||||
*/
|
||||
footerEnable?: boolean;
|
||||
/**
|
||||
* footer 是否固定
|
||||
* @default true
|
||||
*/
|
||||
footerFixed?: boolean;
|
||||
/**
|
||||
* footer 高度
|
||||
* @default 32
|
||||
*/
|
||||
footerHeight?: number;
|
||||
|
||||
/**
|
||||
* header高度
|
||||
* @default 48
|
||||
*/
|
||||
headerHeight?: number;
|
||||
/**
|
||||
* 顶栏是否隐藏
|
||||
* @default false
|
||||
*/
|
||||
headerHidden?: boolean;
|
||||
/**
|
||||
* header 显示模式
|
||||
* @default 'fixed'
|
||||
*/
|
||||
headerMode?: LayoutHeaderModeType;
|
||||
/**
|
||||
* header 顶栏主题
|
||||
*/
|
||||
headerTheme?: ThemeModeType;
|
||||
/**
|
||||
* 是否显示header切换侧边栏按钮
|
||||
* @default
|
||||
*/
|
||||
headerToggleSidebarButton?: boolean;
|
||||
/**
|
||||
* header是否显示
|
||||
* @default true
|
||||
*/
|
||||
headerVisible?: boolean;
|
||||
/**
|
||||
* 是否移动端显示
|
||||
* @default false
|
||||
*/
|
||||
isMobile?: boolean;
|
||||
/**
|
||||
* 布局方式
|
||||
* sidebar-nav 侧边菜单布局
|
||||
* header-nav 顶部菜单布局
|
||||
* mixed-nav 侧边&顶部菜单布局
|
||||
* sidebar-mixed-nav 侧边混合菜单布局
|
||||
* full-content 全屏内容布局
|
||||
* @default sidebar-nav
|
||||
*/
|
||||
layout?: LayoutType;
|
||||
/**
|
||||
* 侧边菜单折叠状态
|
||||
* @default false
|
||||
*/
|
||||
sidebarCollapse?: boolean;
|
||||
/**
|
||||
* 侧边菜单折叠按钮
|
||||
* @default true
|
||||
*/
|
||||
sidebarCollapsedButton?: boolean;
|
||||
/**
|
||||
* 侧边菜单是否折叠时,是否显示title
|
||||
* @default true
|
||||
*/
|
||||
sidebarCollapseShowTitle?: boolean;
|
||||
/**
|
||||
* 侧边栏是否可见
|
||||
* @default true
|
||||
*/
|
||||
sidebarEnable?: boolean;
|
||||
/**
|
||||
* 侧边菜单折叠额外宽度
|
||||
* @default 48
|
||||
*/
|
||||
sidebarExtraCollapsedWidth?: number;
|
||||
/**
|
||||
* 扩展区域extra-title的高度
|
||||
*/
|
||||
sidebarExtraTitleHeight?: number;
|
||||
/**
|
||||
* 侧边菜单折叠按钮是否固定
|
||||
* @default true
|
||||
*/
|
||||
sidebarFixedButton?: boolean;
|
||||
/**
|
||||
* 侧边栏是否隐藏
|
||||
* @default false
|
||||
*/
|
||||
sidebarHidden?: boolean;
|
||||
/**
|
||||
* 侧边栏 Logo 区域是否显示
|
||||
*/
|
||||
sidebarLogoVisible: boolean;
|
||||
/**
|
||||
* 混合侧边栏宽度
|
||||
* @default 80
|
||||
*/
|
||||
sidebarMixedWidth?: number;
|
||||
/**
|
||||
* 侧边栏
|
||||
* @default dark
|
||||
*/
|
||||
sidebarTheme?: ThemeModeType;
|
||||
/**
|
||||
* 侧边栏子栏
|
||||
* @default dark
|
||||
*/
|
||||
sidebarThemeSub?: ThemeModeType;
|
||||
/**
|
||||
* 侧边栏宽度
|
||||
* @default 210
|
||||
*/
|
||||
sidebarWidth?: number;
|
||||
/**
|
||||
* 侧边菜单折叠宽度
|
||||
* @default 48
|
||||
*/
|
||||
sideCollapseWidth?: number;
|
||||
/**
|
||||
* tab是否可见
|
||||
* @default true
|
||||
*/
|
||||
tabbarEnable?: boolean;
|
||||
/**
|
||||
* tab高度
|
||||
* @default 30
|
||||
*/
|
||||
tabbarHeight?: number;
|
||||
/**
|
||||
* zIndex
|
||||
* @default 100
|
||||
*/
|
||||
zIndex?: number;
|
||||
}
|
||||
export type { VbenLayoutProps };
|
||||
@@ -0,0 +1,766 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import type { VbenLayoutProps } from './vben-layout';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
SCROLL_FIXED_CLASS,
|
||||
useLayoutFooterStyle,
|
||||
useLayoutHeaderStyle,
|
||||
useLayoutViewportHeight,
|
||||
} from '@vben-core/composables';
|
||||
import { IconifyIcon } from '@vben-core/icons';
|
||||
import { VbenIconButton } from '@vben-core/shadcn-ui';
|
||||
import {
|
||||
ELEMENT_ID_LAYOUT_SCROLL,
|
||||
ELEMENT_ID_MAIN_CONTENT,
|
||||
} from '@vben-core/shared/constants';
|
||||
|
||||
import { useEventListener, useScroll } from '@vueuse/core';
|
||||
|
||||
import {
|
||||
LayoutContent,
|
||||
LayoutFooter,
|
||||
LayoutHeader,
|
||||
LayoutSidebar,
|
||||
LayoutTabbar,
|
||||
} from './components';
|
||||
import { resolveHeaderHiddenOnScroll } from './header-scroll-state';
|
||||
import { useLayout } from './hooks/use-layout';
|
||||
|
||||
interface Props extends VbenLayoutProps {}
|
||||
|
||||
defineOptions({
|
||||
name: 'VbenLayout',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
contentCompact: 'wide',
|
||||
contentCompactWidth: 1200,
|
||||
contentPadding: 0,
|
||||
contentPaddingBottom: 0,
|
||||
contentPaddingLeft: 0,
|
||||
contentPaddingRight: 0,
|
||||
contentPaddingTop: 0,
|
||||
footerEnable: false,
|
||||
footerFixed: true,
|
||||
footerHeight: 32,
|
||||
headerHeight: 50,
|
||||
headerHidden: false,
|
||||
headerMode: 'fixed',
|
||||
headerToggleSidebarButton: true,
|
||||
headerVisible: true,
|
||||
isMobile: false,
|
||||
layout: 'sidebar-nav',
|
||||
sidebarCollapsedButton: true,
|
||||
sidebarCollapseShowTitle: false,
|
||||
sidebarExtraCollapsedWidth: 60,
|
||||
sidebarFixedButton: true,
|
||||
sidebarHidden: false,
|
||||
sidebarMixedWidth: 80,
|
||||
sidebarTheme: 'dark',
|
||||
sidebarThemeSub: 'dark',
|
||||
sidebarWidth: 180,
|
||||
sideCollapseWidth: 60,
|
||||
tabbarEnable: true,
|
||||
tabbarHeight: 40,
|
||||
zIndex: 200,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
sideMouseLeave: [];
|
||||
toggleSidebar: [];
|
||||
'update:sidebarWidth': [value: number];
|
||||
}>();
|
||||
const sidebarDraggable = defineModel<boolean>('sidebarDraggable', {
|
||||
default: true,
|
||||
});
|
||||
const sidebarCollapse = defineModel<boolean>('sidebarCollapse', {
|
||||
default: false,
|
||||
});
|
||||
const sidebarExtraVisible = defineModel<boolean>('sidebarExtraVisible');
|
||||
const sidebarExtraCollapse = defineModel<boolean>('sidebarExtraCollapse', {
|
||||
default: false,
|
||||
});
|
||||
const sidebarExpandOnHover = defineModel<boolean>('sidebarExpandOnHover', {
|
||||
default: false,
|
||||
});
|
||||
const sidebarEnable = defineModel<boolean>('sidebarEnable', { default: true });
|
||||
|
||||
const HEADER_TRIGGER_DISTANCE = 12;
|
||||
|
||||
// side是否处于hover状态展开菜单中
|
||||
const sidebarExpandOnHovering = ref(false);
|
||||
const mobileSidebarOpen = ref(false);
|
||||
const headerIsHidden = ref(false);
|
||||
const mainRef = ref<HTMLElement | null>(null);
|
||||
const contentRef = ref<HTMLElement | null>(null);
|
||||
let lastMouseY: null | number = null;
|
||||
|
||||
const {
|
||||
arrivedState,
|
||||
directions,
|
||||
y: scrollY,
|
||||
} = useScroll(contentRef, {
|
||||
onScroll: handleLayoutScroll,
|
||||
});
|
||||
|
||||
useLayoutViewportHeight();
|
||||
const { setLayoutHeaderHeight } = useLayoutHeaderStyle();
|
||||
const { setLayoutFooterHeight } = useLayoutFooterStyle();
|
||||
|
||||
const {
|
||||
currentLayout,
|
||||
isFullContent,
|
||||
isHeaderMixedNav,
|
||||
isHeaderNav,
|
||||
isMixedNav,
|
||||
isSidebarMixedNav,
|
||||
} = useLayout(props);
|
||||
|
||||
/**
|
||||
* 顶栏是否自动隐藏
|
||||
*/
|
||||
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(() => {
|
||||
let height = 0;
|
||||
if (props.headerVisible && !props.headerHidden) {
|
||||
height += props.headerHeight;
|
||||
}
|
||||
if (props.tabbarEnable) {
|
||||
height += props.tabbarHeight;
|
||||
}
|
||||
return height;
|
||||
});
|
||||
|
||||
const getSideCollapseWidth = computed(() => {
|
||||
const {
|
||||
sidebarCollapseShowTitle,
|
||||
sidebarExtraCollapsedWidth,
|
||||
sideCollapseWidth,
|
||||
} = props;
|
||||
|
||||
return sidebarCollapseShowTitle ||
|
||||
isSidebarMixedNav.value ||
|
||||
isHeaderMixedNav.value
|
||||
? sidebarExtraCollapsedWidth
|
||||
: sideCollapseWidth;
|
||||
});
|
||||
|
||||
const activeSidebarCollapse = computed({
|
||||
get: () =>
|
||||
props.isMobile ? !mobileSidebarOpen.value : sidebarCollapse.value,
|
||||
set: (value: boolean) => {
|
||||
if (props.isMobile) {
|
||||
mobileSidebarOpen.value = !value;
|
||||
return;
|
||||
}
|
||||
sidebarCollapse.value = value;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 动态获取侧边区域是否可见
|
||||
*/
|
||||
const sidebarEnableState = computed(() => {
|
||||
return !isHeaderNav.value && sidebarEnable.value;
|
||||
});
|
||||
|
||||
/**
|
||||
* 侧边区域离顶部高度
|
||||
*/
|
||||
const sidebarMarginTop = computed(() => {
|
||||
const { headerHeight, isMobile } = props;
|
||||
return isMixedNav.value && !isMobile ? headerHeight : 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* 动态获取侧边宽度
|
||||
*/
|
||||
const getSidebarWidth = computed(() => {
|
||||
const { isMobile, sidebarHidden, sidebarMixedWidth, sidebarWidth } = props;
|
||||
let width = 0;
|
||||
|
||||
if (sidebarHidden) {
|
||||
return width;
|
||||
}
|
||||
|
||||
if (
|
||||
!sidebarEnableState.value ||
|
||||
(sidebarHidden &&
|
||||
!isSidebarMixedNav.value &&
|
||||
!isMixedNav.value &&
|
||||
!isHeaderMixedNav.value)
|
||||
) {
|
||||
return width;
|
||||
}
|
||||
|
||||
if ((isHeaderMixedNav.value || isSidebarMixedNav.value) && !isMobile) {
|
||||
width = sidebarMixedWidth;
|
||||
} else if (activeSidebarCollapse.value) {
|
||||
width = isMobile ? 0 : getSideCollapseWidth.value;
|
||||
} else {
|
||||
width = sidebarWidth;
|
||||
}
|
||||
return width;
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取扩展区域宽度
|
||||
*/
|
||||
const sidebarExtraWidth = computed(() => {
|
||||
const { sidebarExtraCollapsedWidth, sidebarWidth } = props;
|
||||
|
||||
return sidebarExtraCollapse.value ? sidebarExtraCollapsedWidth : sidebarWidth;
|
||||
});
|
||||
|
||||
/**
|
||||
* 是否侧边栏模式,包含混合侧边
|
||||
*/
|
||||
const isSideMode = computed(
|
||||
() =>
|
||||
currentLayout.value === 'mixed-nav' ||
|
||||
currentLayout.value === 'sidebar-mixed-nav' ||
|
||||
currentLayout.value === 'sidebar-nav' ||
|
||||
currentLayout.value === 'header-mixed-nav' ||
|
||||
currentLayout.value === 'header-sidebar-nav',
|
||||
);
|
||||
|
||||
/**
|
||||
* header fixed值
|
||||
*/
|
||||
const headerFixed = computed(() => {
|
||||
const { headerMode } = props;
|
||||
return (
|
||||
isMixedNav.value ||
|
||||
headerMode === 'fixed' ||
|
||||
headerMode === 'auto-scroll' ||
|
||||
headerMode === 'auto'
|
||||
);
|
||||
});
|
||||
|
||||
const showSidebar = computed(() => {
|
||||
return isSideMode.value && sidebarEnable.value && !props.sidebarHidden;
|
||||
});
|
||||
|
||||
/**
|
||||
* 遮罩可见性
|
||||
*/
|
||||
const maskVisible = computed(
|
||||
() => !activeSidebarCollapse.value && props.isMobile,
|
||||
);
|
||||
|
||||
const mainStyle = computed(() => {
|
||||
let width = '100%';
|
||||
let sidebarAndExtraWidth = 'unset';
|
||||
if (
|
||||
headerFixed.value &&
|
||||
currentLayout.value !== 'header-nav' &&
|
||||
currentLayout.value !== 'mixed-nav' &&
|
||||
currentLayout.value !== 'header-sidebar-nav' &&
|
||||
showSidebar.value &&
|
||||
!props.isMobile
|
||||
) {
|
||||
// fixed模式下生效
|
||||
const isSideNavEffective =
|
||||
(isSidebarMixedNav.value || isHeaderMixedNav.value) &&
|
||||
sidebarExpandOnHover.value &&
|
||||
sidebarExtraVisible.value;
|
||||
|
||||
if (isSideNavEffective) {
|
||||
const sideCollapseWidth = props.sidebarMixedWidth;
|
||||
const sideWidth = sidebarExtraCollapse.value
|
||||
? props.sidebarExtraCollapsedWidth
|
||||
: props.sidebarWidth;
|
||||
|
||||
// 100% - 侧边菜单混合宽度 - 菜单宽度
|
||||
sidebarAndExtraWidth = `${sideCollapseWidth + sideWidth}px`;
|
||||
width = `calc(100% - ${sidebarAndExtraWidth})`;
|
||||
} else {
|
||||
let sidebarWidth = getSidebarWidth.value;
|
||||
if (sidebarExpandOnHovering.value && !sidebarExpandOnHover.value) {
|
||||
sidebarWidth =
|
||||
isSidebarMixedNav.value || isHeaderMixedNav.value
|
||||
? props.sidebarMixedWidth
|
||||
: getSideCollapseWidth.value;
|
||||
}
|
||||
sidebarAndExtraWidth = `${sidebarWidth}px`;
|
||||
width = `calc(100% - ${sidebarAndExtraWidth})`;
|
||||
}
|
||||
}
|
||||
return {
|
||||
sidebarAndExtraWidth,
|
||||
width,
|
||||
};
|
||||
});
|
||||
|
||||
// 计算 tabbar 的样式
|
||||
const tabbarStyle = computed((): CSSProperties => {
|
||||
let width: string;
|
||||
let marginLeft = 0;
|
||||
|
||||
// 如果不是混合导航,tabbar 的宽度为 100%
|
||||
if (!isMixedNav.value || props.sidebarHidden) {
|
||||
width = '100%';
|
||||
} else if (sidebarEnable.value) {
|
||||
// 鼠标在侧边栏上时,且侧边栏展开时的宽度
|
||||
const onHoveringWidth = sidebarExpandOnHover.value
|
||||
? props.sidebarWidth
|
||||
: getSideCollapseWidth.value;
|
||||
|
||||
// 设置 marginLeft,根据侧边栏是否折叠来决定
|
||||
marginLeft = activeSidebarCollapse.value
|
||||
? getSideCollapseWidth.value
|
||||
: onHoveringWidth;
|
||||
|
||||
// 设置 tabbar 的宽度,计算方式为 100% 减去侧边栏的宽度
|
||||
width = `calc(100% - ${activeSidebarCollapse.value ? getSidebarWidth.value : onHoveringWidth}px)`;
|
||||
} else {
|
||||
// 默认情况下,tabbar 的宽度为 100%
|
||||
width = '100%';
|
||||
}
|
||||
|
||||
return {
|
||||
marginLeft: `${marginLeft}px`,
|
||||
width,
|
||||
};
|
||||
});
|
||||
|
||||
const layoutScrollStyle = computed((): CSSProperties => {
|
||||
const fixed = headerFixed.value;
|
||||
|
||||
if (!fixed) {
|
||||
return {
|
||||
marginTop: 0,
|
||||
paddingTop: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (isHeaderOverlayModeActive.value) {
|
||||
return {
|
||||
marginTop: 0,
|
||||
paddingTop: isFullContent.value ? 0 : `${headerWrapperHeight.value}px`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
marginTop:
|
||||
fixed &&
|
||||
!isFullContent.value &&
|
||||
!headerIsHidden.value &&
|
||||
(!isHeaderAutoActive.value || scrollY.value < headerWrapperHeight.value)
|
||||
? `${headerWrapperHeight.value}px`
|
||||
: 0,
|
||||
paddingTop: 0,
|
||||
};
|
||||
});
|
||||
|
||||
const contentStyle = computed((): CSSProperties => {
|
||||
const { footerEnable, footerFixed, footerHeight } = props;
|
||||
return {
|
||||
paddingBottom: `${footerEnable && footerFixed ? footerHeight : 0}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const headerZIndex = computed(() => {
|
||||
const { zIndex } = props;
|
||||
const offset = isMixedNav.value ? 1 : 0;
|
||||
return zIndex + offset;
|
||||
});
|
||||
|
||||
const headerWrapperStyle = computed((): CSSProperties => {
|
||||
const fixed = headerFixed.value;
|
||||
const hidden = headerIsHidden.value || isFullContent.value;
|
||||
|
||||
return {
|
||||
height: isFullContent.value ? '0' : `${headerWrapperHeight.value}px`,
|
||||
left: isMixedNav.value ? 0 : mainStyle.value.sidebarAndExtraWidth,
|
||||
position: fixed ? 'fixed' : 'static',
|
||||
top: 0,
|
||||
transform: fixed
|
||||
? `translate3d(0, ${hidden ? '-100%' : '0'}, 0)`
|
||||
: undefined,
|
||||
transitionDuration: fixed ? undefined : '0ms',
|
||||
width: mainStyle.value.width,
|
||||
willChange: fixed ? 'transform' : undefined,
|
||||
'z-index': headerZIndex.value,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 侧边栏z-index
|
||||
*/
|
||||
const sidebarZIndex = computed(() => {
|
||||
const { isMobile, zIndex } = props;
|
||||
let offset = isMobile || isSideMode.value ? 1 : -1;
|
||||
|
||||
if (isMixedNav.value) {
|
||||
offset += 1;
|
||||
}
|
||||
|
||||
return zIndex + offset;
|
||||
});
|
||||
|
||||
const footerWidth = computed(() => {
|
||||
if (!props.footerFixed) {
|
||||
return '100%';
|
||||
}
|
||||
|
||||
return mainStyle.value.width;
|
||||
});
|
||||
|
||||
const maskStyle = computed((): CSSProperties => {
|
||||
return { zIndex: props.zIndex };
|
||||
});
|
||||
|
||||
/**
|
||||
* 侧边栏 Logo 区域是否显示
|
||||
*/
|
||||
const sidebarHeaderHeight = computed(() => {
|
||||
if (isMixedNav.value || !props.sidebarLogoVisible) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return props.headerHeight;
|
||||
});
|
||||
|
||||
const showHeaderToggleButton = computed(() => {
|
||||
return (
|
||||
props.isMobile ||
|
||||
(props.headerToggleSidebarButton &&
|
||||
isSideMode.value &&
|
||||
!isSidebarMixedNav.value &&
|
||||
!isMixedNav.value &&
|
||||
!props.isMobile)
|
||||
);
|
||||
});
|
||||
|
||||
const showHeaderLogo = computed(() => {
|
||||
return !isSideMode.value || isMixedNav.value || props.isMobile;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.isMobile,
|
||||
(isMobile) => {
|
||||
if (isMobile) {
|
||||
mobileSidebarOpen.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
[() => headerWrapperHeight.value, () => isFullContent.value],
|
||||
([height]) => {
|
||||
setLayoutHeaderHeight(isFullContent.value ? 0 : height);
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.footerHeight,
|
||||
(height: number) => {
|
||||
setLayoutFooterHeight(height);
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
[() => props.headerMode, () => isMixedNav.value, () => isFullContent.value],
|
||||
() => {
|
||||
headerIsHidden.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
useEventListener(mainRef, 'mousemove', handleHeaderMouseMove, {
|
||||
passive: true,
|
||||
});
|
||||
useEventListener(mainRef, 'wheel', handleLayoutWheel, {
|
||||
passive: true,
|
||||
});
|
||||
|
||||
function handleLayoutWheel(event: WheelEvent) {
|
||||
lastMouseY = event.clientY;
|
||||
}
|
||||
|
||||
function handleHeaderMouseMove(event: MouseEvent) {
|
||||
lastMouseY = event.clientY;
|
||||
|
||||
if (!isHeaderAutoActive.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateHeaderVisibilityFromMouse(lastMouseY);
|
||||
}
|
||||
|
||||
function updateHeaderVisibilityFromMouse(mouseY: null | number) {
|
||||
if (arrivedState.top || scrollY.value < headerWrapperHeight.value) {
|
||||
headerIsHidden.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (mouseY === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isInTriggerZone = mouseY <= HEADER_TRIGGER_DISTANCE;
|
||||
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() {
|
||||
activeSidebarCollapse.value = true;
|
||||
}
|
||||
|
||||
function handleHeaderToggle() {
|
||||
if (props.isMobile) {
|
||||
activeSidebarCollapse.value = false;
|
||||
} else {
|
||||
emit('toggleSidebar');
|
||||
}
|
||||
}
|
||||
|
||||
const idMainContent = ELEMENT_ID_MAIN_CONTENT;
|
||||
const idLayoutScroll = ELEMENT_ID_LAYOUT_SCROLL;
|
||||
const idLayoutStaticHeader = `${ELEMENT_ID_LAYOUT_SCROLL}__static_header`;
|
||||
const layoutStaticHeaderTarget = `#${idLayoutStaticHeader}`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-layout-region="layout"
|
||||
:data-layout="currentLayout"
|
||||
:data-mobile="isMobile"
|
||||
:data-sidebar-collapsed="activeSidebarCollapse"
|
||||
class="relative flex h-full min-h-0 w-full overflow-hidden"
|
||||
>
|
||||
<LayoutSidebar
|
||||
v-if="sidebarEnableState"
|
||||
v-model:draggable="sidebarDraggable"
|
||||
v-model:collapse="activeSidebarCollapse"
|
||||
v-model:expand-on-hover="sidebarExpandOnHover"
|
||||
v-model:expand-on-hovering="sidebarExpandOnHovering"
|
||||
v-model:extra-collapse="sidebarExtraCollapse"
|
||||
v-model:extra-visible="sidebarExtraVisible"
|
||||
:show-collapse-button="sidebarCollapsedButton"
|
||||
:show-fixed-button="sidebarFixedButton"
|
||||
:collapse-width="getSideCollapseWidth"
|
||||
:dom-visible="!isMobile"
|
||||
:expanded-width="sidebarWidth"
|
||||
:extra-width="sidebarExtraWidth"
|
||||
:fixed-extra="sidebarExpandOnHover"
|
||||
:header-height="sidebarHeaderHeight"
|
||||
:extra-title-height="
|
||||
isSidebarMixedNav || isHeaderMixedNav ? sidebarExtraTitleHeight : 0
|
||||
"
|
||||
:is-sidebar-mixed="isSidebarMixedNav || isHeaderMixedNav"
|
||||
:is-mobile="isMobile"
|
||||
:margin-top="sidebarMarginTop"
|
||||
:mixed-width="sidebarMixedWidth"
|
||||
:show="showSidebar"
|
||||
:theme="sidebarTheme"
|
||||
:theme-sub="sidebarThemeSub"
|
||||
:width="getSidebarWidth"
|
||||
:z-index="sidebarZIndex"
|
||||
@leave="() => emit('sideMouseLeave')"
|
||||
@update:width="(val) => emit('update:sidebarWidth', val)"
|
||||
>
|
||||
<template v-if="isSideMode && !isMixedNav && sidebarLogoVisible" #logo>
|
||||
<slot name="logo"></slot>
|
||||
</template>
|
||||
|
||||
<template v-if="isSidebarMixedNav || isHeaderMixedNav">
|
||||
<slot name="mixed-menu"></slot>
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot name="menu"></slot>
|
||||
</template>
|
||||
|
||||
<template #extra>
|
||||
<slot name="side-extra"></slot>
|
||||
</template>
|
||||
<template #extra-title>
|
||||
<slot name="side-extra-title"></slot>
|
||||
</template>
|
||||
</LayoutSidebar>
|
||||
|
||||
<div
|
||||
ref="mainRef"
|
||||
data-layout-region="main"
|
||||
class="relative flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<Teleport defer :disabled="headerFixed" :to="layoutStaticHeaderTarget">
|
||||
<div
|
||||
data-layout-region="header"
|
||||
:class="[
|
||||
{
|
||||
'shadow-[0_16px_24px_hsl(var(--background))]': headerHasShadow,
|
||||
},
|
||||
SCROLL_FIXED_CLASS,
|
||||
]"
|
||||
:style="headerWrapperStyle"
|
||||
class="shrink-0 overflow-hidden transition-transform duration-150"
|
||||
>
|
||||
<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"
|
||||
data-layout-action="toggle-sidebar"
|
||||
class="my-0 mr-1 rounded-md"
|
||||
@click="handleHeaderToggle"
|
||||
>
|
||||
<IconifyIcon
|
||||
v-if="isMobile ? !activeSidebarCollapse : 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
|
||||
:id="idLayoutScroll"
|
||||
ref="contentRef"
|
||||
data-layout-region="scroll"
|
||||
:style="layoutScrollStyle"
|
||||
class="flex min-h-0 flex-1 flex-col overflow-x-hidden overflow-y-auto bg-background-deep"
|
||||
>
|
||||
<div :id="idLayoutStaticHeader" class="contents"></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"
|
||||
>
|
||||
<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>
|
||||
<Transition name="mobile-sidebar-mask">
|
||||
<div
|
||||
v-if="maskVisible"
|
||||
data-layout-region="sidebar-mask"
|
||||
:style="maskStyle"
|
||||
class="fixed top-0 left-0 size-full bg-overlay"
|
||||
@click="handleClickMask"
|
||||
></div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mobile-sidebar-mask-enter-active,
|
||||
.mobile-sidebar-mask-leave-active {
|
||||
transition: opacity 300ms ease;
|
||||
}
|
||||
|
||||
.mobile-sidebar-mask-enter-from,
|
||||
.mobile-sidebar-mask-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mobile-sidebar-mask-enter-active,
|
||||
.mobile-sidebar-mask-leave-active {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user