diff --git a/internal/tailwind-config/src/theme.css b/internal/tailwind-config/src/theme.css index 2bf31bbe6..40e199fe6 100644 --- a/internal/tailwind-config/src/theme.css +++ b/internal/tailwind-config/src/theme.css @@ -310,6 +310,8 @@ } html { + --vben-viewport-height: 100vh; + @apply bg-background font-sans text-foreground; scroll-behavior: smooth; @@ -320,6 +322,10 @@ text-rendering: optimizelegibility; text-size-adjust: 100%; -webkit-tap-highlight-color: transparent; + + @supports (height: 1dvh) { + --vben-viewport-height: 100dvh; + } } #app, diff --git a/packages/@core/base/shared/src/constants/globals.ts b/packages/@core/base/shared/src/constants/globals.ts index b86c45cd4..9e897fcae 100644 --- a/packages/@core/base/shared/src/constants/globals.ts +++ b/packages/@core/base/shared/src/constants/globals.ts @@ -6,6 +6,8 @@ export const CSS_VARIABLE_LAYOUT_CONTENT_WIDTH = `--vben-content-width`; export const CSS_VARIABLE_LAYOUT_HEADER_HEIGHT = `--vben-header-height`; /** layout footer 组件的高度 */ export const CSS_VARIABLE_LAYOUT_FOOTER_HEIGHT = `--vben-footer-height`; +/** layout overlay 使用的视口高度,CSS 按 100vh → 100dvh 降级 */ +export const CSS_VARIABLE_LAYOUT_VIEWPORT_HEIGHT = `--vben-viewport-height`; /** 内容区域的组件ID */ export const ELEMENT_ID_MAIN_CONTENT = `__vben_main_content`; diff --git a/packages/@core/composables/src/__tests__/use-layout-viewport-height.test.ts b/packages/@core/composables/src/__tests__/use-layout-viewport-height.test.ts new file mode 100644 index 000000000..3fea8d342 --- /dev/null +++ b/packages/@core/composables/src/__tests__/use-layout-viewport-height.test.ts @@ -0,0 +1,139 @@ +import type { App } from 'vue'; + +import { createApp } from 'vue'; + +import { CSS_VARIABLE_LAYOUT_VIEWPORT_HEIGHT } from '@vben-core/shared/constants'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useLayoutViewportHeight } from '../use-layout-viewport-height'; + +let activeApp: App | undefined; + +function stubDvhSupport(supported: boolean) { + vi.stubGlobal('CSS', { + supports: vi.fn((property: string, value: string) => { + return supported && property === 'height' && value === '1dvh'; + }), + }); +} + +function mountViewportHeight() { + const host = document.createElement('div'); + document.body.append(host); + + activeApp = createApp({ + setup() { + useLayoutViewportHeight(); + return () => null; + }, + }); + activeApp.mount(host); +} + +function getViewportHeightVar() { + return document.documentElement.style.getPropertyValue( + CSS_VARIABLE_LAYOUT_VIEWPORT_HEIGHT, + ); +} + +async function flushAnimationFrame() { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); +} + +afterEach(() => { + activeApp?.unmount(); + activeApp = undefined; + document.body.innerHTML = ''; + document.documentElement.style.removeProperty( + CSS_VARIABLE_LAYOUT_VIEWPORT_HEIGHT, + ); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('useLayoutViewportHeight', () => { + it('does not write the CSS variable when dvh is supported', () => { + const resizeObserver = vi.fn(); + const addEventListener = vi.spyOn(window, 'addEventListener'); + vi.stubGlobal('ResizeObserver', resizeObserver); + stubDvhSupport(true); + + mountViewportHeight(); + + expect(getViewportHeightVar()).toBe(''); + expect(resizeObserver).not.toHaveBeenCalled(); + expect(addEventListener).not.toHaveBeenCalledWith( + 'resize', + expect.any(Function), + ); + }); + + it('writes innerHeight when dvh is unsupported', () => { + const resizeObserver = vi.fn(); + vi.stubGlobal('ResizeObserver', resizeObserver); + stubDvhSupport(false); + vi.stubGlobal('visualViewport', null); + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800); + + mountViewportHeight(); + + expect(getViewportHeightVar()).toBe('800px'); + expect(resizeObserver).not.toHaveBeenCalled(); + }); + + it('prefers visualViewport.height over innerHeight', () => { + stubDvhSupport(false); + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800); + const visualViewport = new EventTarget() as VisualViewport; + Object.defineProperty(visualViewport, 'height', { + configurable: true, + value: 640, + writable: true, + }); + vi.stubGlobal('visualViewport', visualViewport); + + mountViewportHeight(); + + expect(getViewportHeightVar()).toBe('640px'); + }); + + it('updates the CSS variable on visualViewport resize and stops after unmount', async () => { + stubDvhSupport(false); + const visualViewport = new EventTarget() as VisualViewport; + Object.defineProperty(visualViewport, 'height', { + configurable: true, + value: 640, + writable: true, + }); + vi.stubGlobal('visualViewport', visualViewport); + + mountViewportHeight(); + expect(getViewportHeightVar()).toBe('640px'); + + Object.defineProperty(visualViewport, 'height', { + configurable: true, + value: 520, + writable: true, + }); + visualViewport.dispatchEvent(new Event('resize')); + await flushAnimationFrame(); + + expect(getViewportHeightVar()).toBe('520px'); + + activeApp?.unmount(); + activeApp = undefined; + + Object.defineProperty(visualViewport, 'height', { + configurable: true, + value: 400, + writable: true, + }); + visualViewport.dispatchEvent(new Event('resize')); + await flushAnimationFrame(); + + expect(getViewportHeightVar()).toBe('520px'); + }); +}); diff --git a/packages/@core/composables/src/index.ts b/packages/@core/composables/src/index.ts index 2dbd4b807..8988cb87c 100644 --- a/packages/@core/composables/src/index.ts +++ b/packages/@core/composables/src/index.ts @@ -1,5 +1,6 @@ export * from './use-is-mobile'; export * from './use-layout-style'; +export * from './use-layout-viewport-height'; export * from './use-namespace'; export * from './use-priority-value'; export * from './use-scroll-lock'; diff --git a/packages/@core/composables/src/use-layout-viewport-height.ts b/packages/@core/composables/src/use-layout-viewport-height.ts new file mode 100644 index 000000000..c3c429661 --- /dev/null +++ b/packages/@core/composables/src/use-layout-viewport-height.ts @@ -0,0 +1,71 @@ +import { onMounted, onUnmounted } from 'vue'; + +import { CSS_VARIABLE_LAYOUT_VIEWPORT_HEIGHT } from '@vben-core/shared/constants'; + +import { useCssVar, useEventListener } from '@vueuse/core'; + +function supportsDynamicViewportHeight() { + return ( + globalThis.CSS !== undefined && + typeof globalThis.CSS.supports === 'function' && + globalThis.CSS.supports('height', '1dvh') + ); +} + +function readViewportHeight() { + return Math.round(window.visualViewport?.height ?? window.innerHeight); +} + +/** + * 仅在不支持 dvh 时,把 --vben-viewport-height 写成像素值。 + * 支持 dvh 时保持 CSS 的 100vh → 100dvh 级联,避免 useCssVar 把单位冻成 px。 + */ +export function useLayoutViewportHeight() { + if (typeof window === 'undefined' || supportsDynamicViewportHeight()) { + return; + } + + const viewportHeight = useCssVar( + CSS_VARIABLE_LAYOUT_VIEWPORT_HEIGHT, + document.documentElement, + { observe: false }, + ); + + let frameId = 0; + + function applyViewportHeight() { + viewportHeight.value = `${readViewportHeight()}px`; + } + + function scheduleApplyViewportHeight() { + if (frameId) { + return; + } + + frameId = window.requestAnimationFrame(() => { + frameId = 0; + applyViewportHeight(); + }); + } + + applyViewportHeight(); + onMounted(applyViewportHeight); + useEventListener(window, 'resize', scheduleApplyViewportHeight); + + if (window.visualViewport) { + useEventListener( + window.visualViewport, + 'resize', + scheduleApplyViewportHeight, + ); + } + + onUnmounted(() => { + if (!frameId) { + return; + } + + window.cancelAnimationFrame(frameId); + frameId = 0; + }); +} diff --git a/packages/@core/ui-kit/layout-ui/src/components/layout-content.vue b/packages/@core/ui-kit/layout-ui/src/components/layout-content.vue index 3a04b71a6..78067f425 100644 --- a/packages/@core/ui-kit/layout-ui/src/components/layout-content.vue +++ b/packages/@core/ui-kit/layout-ui/src/components/layout-content.vue @@ -25,7 +25,7 @@ const props = withDefaults(defineProps(), {}); const overlayViewportStyle: CSSProperties = { height: - 'calc(100dvh - var(--vben-header-height, 0px) - var(--vben-footer-height, 0px))', + 'calc(var(--vben-viewport-height) - var(--vben-header-height, 0px) - var(--vben-footer-height, 0px))', }; const style = computed((): CSSProperties => { @@ -65,6 +65,7 @@ const style = computed((): CSSProperties => { >
diff --git a/packages/@core/ui-kit/layout-ui/src/vben-layout.vue b/packages/@core/ui-kit/layout-ui/src/vben-layout.vue index 4407d94e2..55f44c857 100644 --- a/packages/@core/ui-kit/layout-ui/src/vben-layout.vue +++ b/packages/@core/ui-kit/layout-ui/src/vben-layout.vue @@ -9,6 +9,7 @@ import { SCROLL_FIXED_CLASS, useLayoutFooterStyle, useLayoutHeaderStyle, + useLayoutViewportHeight, } from '@vben-core/composables'; import { IconifyIcon } from '@vben-core/icons'; import { VbenIconButton } from '@vben-core/shadcn-ui'; @@ -106,6 +107,7 @@ const { onScroll: handleLayoutScroll, }); +useLayoutViewportHeight(); const { setLayoutHeaderHeight } = useLayoutHeaderStyle(); const { setLayoutFooterHeight } = useLayoutFooterStyle();