Browse Source

refactor(@vben-core/layout-ui): move layout sizing and scrolling to CSS

pull/8252/head
Dream 4 weeks ago
parent
commit
89c05f8274
  1. 43
      packages/@core/composables/src/use-layout-style.ts
  2. 28
      packages/@core/ui-kit/layout-ui/src/components/layout-content.vue
  3. 228
      packages/@core/ui-kit/layout-ui/src/components/layout-sidebar.vue
  4. 2
      packages/@core/ui-kit/layout-ui/src/components/layout-tabbar.vue
  5. 8
      packages/@core/ui-kit/layout-ui/src/components/widgets/sidebar-collapse-button.vue
  6. 96
      packages/@core/ui-kit/layout-ui/src/vben-layout.vue
  7. 3
      packages/@core/ui-kit/shadcn-ui/src/components/spinner/spinner.vue

43
packages/@core/composables/src/use-layout-style.ts

@ -2,61 +2,26 @@ import type { CSSProperties } from 'vue';
import type { VisibleDomRect } from '@vben-core/shared/utils';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { computed, ref } from 'vue';
import {
CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT,
CSS_VARIABLE_LAYOUT_CONTENT_WIDTH,
CSS_VARIABLE_LAYOUT_FOOTER_HEIGHT,
CSS_VARIABLE_LAYOUT_HEADER_HEIGHT,
} from '@vben-core/shared/constants';
import { getElementVisibleRect } from '@vben-core/shared/utils';
import { useCssVar, useDebounceFn } from '@vueuse/core';
import { useCssVar } from '@vueuse/core';
/**
* @zh_CN content style
*/
export function useLayoutContentStyle() {
let resizeObserver: null | ResizeObserver = null;
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: 150,
};
});
const debouncedCalcHeight = useDebounceFn(
(_entries: ResizeObserverEntry[]) => {
visibleDomRect.value = getElementVisibleRect(contentElement.value);
contentHeight.value = `${visibleDomRect.value.height}px`;
contentWidth.value = `${visibleDomRect.value.width}px`;
},
16,
const overlayStyle = computed(
(): CSSProperties => ({ inset: 0, position: 'absolute', zIndex: 150 }),
);
onMounted(() => {
if (contentElement.value && !resizeObserver) {
resizeObserver = new ResizeObserver(debouncedCalcHeight);
resizeObserver.observe(contentElement.value);
}
});
onUnmounted(() => {
resizeObserver?.disconnect();
resizeObserver = null;
});
return { contentElement, overlayStyle, visibleDomRect };
}

28
packages/@core/ui-kit/layout-ui/src/components/layout-content.vue

@ -5,9 +5,6 @@ import type { ContentCompactType } from '@vben-core/typings';
import { computed } from 'vue';
import { useLayoutContentStyle } from '@vben-core/composables';
import { Slot } from '@vben-core/shadcn-ui';
interface Props {
/**
* 内容区域定宽
@ -26,8 +23,10 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {});
// @ts-expect-error - unused
const { contentElement, overlayStyle } = useLayoutContentStyle();
const overlayViewportStyle: CSSProperties = {
height:
'calc(100dvh - var(--vben-header-height, 0px) - var(--vben-footer-height, 0px))',
};
const style = computed((): CSSProperties => {
const {
@ -46,6 +45,8 @@ const style = computed((): CSSProperties => {
return {
...compactStyle,
flex: 1,
minHeight: 0,
minWidth: 0,
padding: `${padding}px`,
paddingBottom: `${paddingBottom}px`,
paddingLeft: `${paddingLeft}px`,
@ -56,10 +57,19 @@ const style = computed((): CSSProperties => {
</script>
<template>
<main ref="contentElement" :style="style" class="relative bg-background-deep">
<Slot :style="overlayStyle">
<slot name="overlay"></slot>
</Slot>
<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"
class="pointer-events-none relative min-h-0 w-full"
>
<slot name="overlay"></slot>
</div>
</div>
<slot></slot>
</main>
</template>

228
packages/@core/ui-kit/layout-ui/src/components/layout-sidebar.vue

@ -25,6 +25,10 @@ interface Props {
* @default true
*/
domVisible?: boolean;
/**
* 标准侧栏展开宽度
*/
expandedWidth?: number;
/**
* 扩展区域extra-title的高度
*/
@ -42,6 +46,11 @@ interface Props {
* 头部高度
*/
headerHeight: number;
/**
* 是否移动端抽屉模式
* @default false
*/
isMobile?: boolean;
/**
* 是否侧边混合模式
* @default false
@ -100,8 +109,10 @@ 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,
@ -126,14 +137,36 @@ const slots = useSlots();
const asideRef = shallowRef<HTMLElement | null>(null);
const dragBarRef = shallowRef<HTMLElement | null>(null);
const hiddenSideStyle = computed((): CSSProperties => calcMenuWidthStyle(true));
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(false),
...calcMenuWidthStyle(),
height: `calc(100% - ${marginTop}px)`,
marginTop: `${marginTop}px`,
paddingTop: `${paddingTop}px`,
@ -206,14 +239,13 @@ watchEffect(() => {
extraVisible.value = props.fixedExtra ? true : extraVisible.value;
});
function calcMenuWidthStyle(isHiddenDom: boolean): CSSProperties {
function getMenuWidthValue(isHiddenDom: boolean) {
const {
collapseWidth,
extraWidth,
mixedWidth,
fixedExtra,
isSidebarMixed,
show,
width,
} = props;
@ -225,13 +257,22 @@ function calcMenuWidthStyle(isHiddenDom: boolean): CSSProperties {
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);
return {
...(widthValue === '0px' ? { overflow: 'hidden' } : {}),
flex: `0 0 ${widthValue}`,
marginLeft: show ? 0 : `-${widthValue}`,
maxWidth: widthValue,
minWidth: widthValue,
width: widthValue,
clipPath: `inset(0 ${clippedWidth}px 0 0)`,
transform: props.isMobile
? undefined
: props.show
? 'translate3d(0, 0, 0)'
: 'translate3d(-100%, 0, 0)',
width: `${sidebarVisualWidth.value}px`,
};
}
@ -307,80 +348,113 @@ onUnmounted(() => {
v-if="domVisible"
:class="theme"
:style="hiddenSideStyle"
class="h-full transition-all duration-150"
class="h-full"
></div>
<aside
ref="asideRef"
:style="style"
class="fixed left-0 top-0 h-full transition-all duration-150"
:class="theme"
@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"
<Transition name="mobile-sidebar">
<aside
v-if="!isMobile || !collapse"
ref="asideRef"
data-layout-region="sidebar"
:style="style"
class="fixed left-0 top-0 h-full"
:class="[
themeSub,
theme,
{
'border-l': extraVisible,
'border-r border-border bg-sidebar transition-[clip-path,transform] duration-300 ease-out':
!isMobile && !isSidebarMixed,
'transition-transform duration-300 ease-out':
!isMobile && isSidebarMixed,
},
]"
:style="extraStyle"
class="fixed top-0 h-full overflow-hidden border-r border-border bg-sidebar transition-all duration-200"
@mouseenter="handleMouseenter"
@mouseleave="handleMouseleave"
>
<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
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>
<VbenScrollbar
:style="extraContentStyle"
class="border-border py-2"
shadow
shadow-border
<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"
>
<slot name="extra"></slot>
</VbenScrollbar>
</div>
<div
v-if="draggable"
ref="dragBarRef"
class="absolute inset-y-0 -right-px z-1000 w-0.5 cursor-col-resize hover:bg-primary"
@mousedown="handleDragSidebar"
></div>
</aside>
<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>

2
packages/@core/ui-kit/layout-ui/src/components/layout-tabbar.vue

@ -23,7 +23,7 @@ const style = computed((): CSSProperties => {
<template>
<section
:style="style"
class="flex w-full border-b border-border bg-background transition-all"
class="flex w-full border-b border-border bg-background transition-colors duration-200"
>
<slot></slot>
</section>

8
packages/@core/ui-kit/layout-ui/src/components/widgets/sidebar-collapse-button.vue

@ -9,11 +9,15 @@ function handleCollapsed() {
</script>
<template>
<div
<button
type="button"
:aria-label="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
: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" />
</div>
</button>
</template>

96
packages/@core/ui-kit/layout-ui/src/vben-layout.vue

@ -92,6 +92,7 @@ const HEADER_TRIGGER_DISTANCE = 12;
// sidehover
const sidebarExpandOnHovering = ref(false);
const mobileSidebarOpen = ref(false);
const headerIsHidden = ref(false);
const mainRef = ref<HTMLElement | null>(null);
const contentRef = ref<HTMLElement | null>(null);
@ -159,6 +160,18 @@ const getSideCollapseWidth = computed(() => {
: sideCollapseWidth;
});
const activeSidebarCollapse = computed({
get: () =>
props.isMobile ? !mobileSidebarOpen.value : sidebarCollapse.value,
set: (value: boolean) => {
if (props.isMobile) {
mobileSidebarOpen.value = !value;
return;
}
sidebarCollapse.value = value;
},
});
/**
* 动态获取侧边区域是否可见
*/
@ -197,7 +210,7 @@ const getSidebarWidth = computed(() => {
if ((isHeaderMixedNav.value || isSidebarMixedNav.value) && !isMobile) {
width = sidebarMixedWidth;
} else if (sidebarCollapse.value) {
} else if (activeSidebarCollapse.value) {
width = isMobile ? 0 : getSideCollapseWidth.value;
} else {
width = sidebarWidth;
@ -246,7 +259,9 @@ const showSidebar = computed(() => {
/**
* 遮罩可见性
*/
const maskVisible = computed(() => !sidebarCollapse.value && props.isMobile);
const maskVisible = computed(
() => !activeSidebarCollapse.value && props.isMobile,
);
const mainStyle = computed(() => {
let width = '100%';
@ -307,12 +322,12 @@ const tabbarStyle = computed((): CSSProperties => {
: getSideCollapseWidth.value;
// marginLeft
marginLeft = sidebarCollapse.value
marginLeft = activeSidebarCollapse.value
? getSideCollapseWidth.value
: onHoveringWidth;
// tabbar 100%
width = `calc(100% - ${sidebarCollapse.value ? getSidebarWidth.value : onHoveringWidth}px)`;
width = `calc(100% - ${activeSidebarCollapse.value ? getSidebarWidth.value : onHoveringWidth}px)`;
} else {
// tabbar 100%
width = '100%';
@ -439,9 +454,9 @@ const showHeaderLogo = computed(() => {
watch(
() => props.isMobile,
(val) => {
if (val) {
sidebarCollapse.value = true;
(isMobile) => {
if (isMobile) {
mobileSidebarOpen.value = false;
}
},
{
@ -542,12 +557,12 @@ function resolveHeaderVisibilityOnScroll() {
}
function handleClickMask() {
sidebarCollapse.value = true;
activeSidebarCollapse.value = true;
}
function handleHeaderToggle() {
if (props.isMobile) {
sidebarCollapse.value = false;
activeSidebarCollapse.value = false;
} else {
emit('toggleSidebar');
}
@ -560,11 +575,17 @@ const layoutStaticHeaderTarget = `#${idLayoutStaticHeader}`;
</script>
<template>
<div class="relative flex h-full min-h-0 w-full overflow-hidden">
<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="sidebarCollapse"
v-model:collapse="activeSidebarCollapse"
v-model:expand-on-hover="sidebarExpandOnHover"
v-model:expand-on-hovering="sidebarExpandOnHovering"
v-model:extra-collapse="sidebarExtraCollapse"
@ -573,6 +594,7 @@ const layoutStaticHeaderTarget = `#${idLayoutStaticHeader}`;
:show-fixed-button="sidebarFixedButton"
:collapse-width="getSideCollapseWidth"
:dom-visible="!isMobile"
:expanded-width="sidebarWidth"
:extra-width="sidebarExtraWidth"
:fixed-extra="sidebarExpandOnHover"
:header-height="sidebarHeaderHeight"
@ -580,12 +602,13 @@ const layoutStaticHeaderTarget = `#${idLayoutStaticHeader}`;
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"
:width="isMobile ? sidebarWidth : getSidebarWidth"
:z-index="sidebarZIndex"
@leave="() => emit('sideMouseLeave')"
@update:width="(val) => emit('update:sidebarWidth', val)"
@ -611,10 +634,12 @@ const layoutStaticHeaderTarget = `#${idLayoutStaticHeader}`;
<div
ref="mainRef"
class="relative flex min-h-0 flex-1 flex-col overflow-hidden transition-all duration-300 ease-in"
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,
@ -622,7 +647,7 @@ const layoutStaticHeaderTarget = `#${idLayoutStaticHeader}`;
SCROLL_FIXED_CLASS,
]"
:style="headerWrapperStyle"
class="shrink-0 overflow-hidden transition-[transform,left,width] duration-200"
class="shrink-0 overflow-hidden transition-transform duration-150"
>
<LayoutHeader
v-if="headerVisible"
@ -643,10 +668,14 @@ const layoutStaticHeaderTarget = `#${idLayoutStaticHeader}`;
<template #toggle-button>
<VbenIconButton
v-if="showHeaderToggleButton"
data-layout-action="toggle-sidebar"
class="my-0 mr-1 rounded-md"
@click="handleHeaderToggle"
>
<IconifyIcon v-if="showSidebar" icon="ep:fold" />
<IconifyIcon
v-if="isMobile ? !activeSidebarCollapse : showSidebar"
icon="ep:fold"
/>
<IconifyIcon v-else icon="ep:expand" />
</VbenIconButton>
</template>
@ -666,8 +695,9 @@ const layoutStaticHeaderTarget = `#${idLayoutStaticHeader}`;
<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"
class="flex min-h-0 flex-1 flex-col overflow-x-hidden overflow-y-auto bg-background-deep"
>
<div :id="idLayoutStaticHeader" class="contents"></div>
@ -702,11 +732,33 @@ const layoutStaticHeaderTarget = `#${idLayoutStaticHeader}`;
</div>
</div>
<slot name="extra"></slot>
<div
v-if="maskVisible"
:style="maskStyle"
class="fixed top-0 left-0 size-full bg-overlay transition-[background-color] duration-200"
@click="handleClickMask"
></div>
<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>

3
packages/@core/ui-kit/shadcn-ui/src/components/spinner/spinner.vue

@ -65,7 +65,8 @@ function onTransitionEnd() {
cn(
'flex-center bg-overlay-content absolute top-0 left-0 z-100 size-full backdrop-blur-xs transition-all duration-500',
{
'invisible opacity-0': !showSpinner,
'invisible pointer-events-none opacity-0': !showSpinner,
'pointer-events-auto': showSpinner,
},
props.class,
)

Loading…
Cancel
Save