mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-10 10:16:44 +08:00
feat(glass): unify inset navigation contours with the frosted backplate
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
|
||||
import type { GlassFixedShellBackplateLayer } from '@/composables/useGlassFixedShellBackplate'
|
||||
|
||||
interface Props {
|
||||
@@ -12,17 +13,292 @@ interface Props {
|
||||
transitionDurationMs: number
|
||||
}
|
||||
|
||||
/** 共用稳定壁纸背板、但拥有独立外轮廓的导航表面。 */
|
||||
type GeometrySurface = 'sidebar' | 'navbar'
|
||||
|
||||
/** SVG objectBoundingBox 坐标,分别以背板的实际宽和高归一化。 */
|
||||
interface NormalizedClipRect {
|
||||
/** 可见高度占背板高度的比例。 */
|
||||
height: number
|
||||
/** 水平方向圆角半径占背板宽度的比例。 */
|
||||
rx: number
|
||||
/** 垂直方向圆角半径占背板高度的比例。 */
|
||||
ry: number
|
||||
/** 可见宽度占背板宽度的比例。 */
|
||||
width: number
|
||||
/** 相对背板左边缘的位置。 */
|
||||
x: number
|
||||
/** 相对背板上边缘的位置。 */
|
||||
y: number
|
||||
}
|
||||
|
||||
/** 背板实际CSS像素边界,不使用可能包含滚动条的window.innerWidth。 */
|
||||
interface BackplateBounds {
|
||||
/** 真实高度。 */
|
||||
height: number
|
||||
/** 实际左侧视口坐标。 */
|
||||
left: number
|
||||
/** 实际顶部视口坐标。 */
|
||||
top: number
|
||||
/** 排除滚动条后的实际宽度。 */
|
||||
width: number
|
||||
}
|
||||
|
||||
const GEOMETRY_SURFACES: readonly GeometrySurface[] = ['sidebar', 'navbar']
|
||||
const GEOMETRY_ATTRIBUTE_FILTER = [
|
||||
'class',
|
||||
'data-glass-appearance',
|
||||
'data-glass-quality',
|
||||
'data-shell-display-environment',
|
||||
'data-shell-mode',
|
||||
'data-shell-navbar-attachment',
|
||||
'data-theme',
|
||||
'style',
|
||||
]
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const mainBackplateRef = ref<HTMLElement | null>(null)
|
||||
const clipRects = ref<NormalizedClipRect[]>([])
|
||||
const clipPathId = `glass-fixed-shell-clip-${useId().replace(/[^a-zA-Z0-9_-]/gu, '-')}`
|
||||
const transitionStyle = computed(() => ({
|
||||
'--glass-fixed-shell-transition-duration': `${Math.max(0, props.transitionDurationMs)}ms`,
|
||||
}))
|
||||
const mainBackplateStyle = computed(() => ({
|
||||
...transitionStyle.value,
|
||||
clipPath: clipRects.value.length === GEOMETRY_SURFACES.length ? `url(#${clipPathId})` : undefined,
|
||||
}))
|
||||
|
||||
const observedElements: Record<GeometrySurface, HTMLElement | null> = {
|
||||
navbar: null,
|
||||
sidebar: null,
|
||||
}
|
||||
const transitionHandlers: Record<GeometrySurface, EventListener | null> = {
|
||||
navbar: null,
|
||||
sidebar: null,
|
||||
}
|
||||
|
||||
let isMounted = false
|
||||
let observedShell: HTMLElement | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let stateObserver: MutationObserver | null = null
|
||||
let geometrySyncQueued = false
|
||||
let usesResizeFallback = false
|
||||
let lastGeometryKey = ''
|
||||
|
||||
function getLayoutShell() {
|
||||
return mainBackplateRef.value?.closest('.layout-wrapper') as HTMLElement | null
|
||||
}
|
||||
|
||||
/** 仅为桌面连接式导航启用几何裁剪,其他壳层继续使用主题原有 CSS 裁剪。 */
|
||||
function isConnectedDesktopShell(shell: HTMLElement) {
|
||||
return (
|
||||
document.documentElement.dataset.theme === 'glass' &&
|
||||
!props.isOverlayNav &&
|
||||
shell.dataset.shellMode === 'desktop' &&
|
||||
shell.dataset.shellNavbarAttachment === 'connected' &&
|
||||
!shell.classList.contains('layout-horizontal-nav-active') &&
|
||||
!shell.classList.contains('layout-overlay-nav') &&
|
||||
!shell.classList.contains('layout-app-shell') &&
|
||||
!shell.classList.contains('layout-window-controls-overlay-shell')
|
||||
)
|
||||
}
|
||||
|
||||
function readBackplateBounds(): BackplateBounds | null {
|
||||
const backplate = mainBackplateRef.value
|
||||
if (!backplate) return null
|
||||
|
||||
const bounds = backplate.getBoundingClientRect()
|
||||
const width = Number.isFinite(bounds.width) ? Math.max(0, bounds.width) : 0
|
||||
const height = Number.isFinite(bounds.height) ? Math.max(0, bounds.height) : 0
|
||||
if (width <= 0 || height <= 0) return null
|
||||
|
||||
return {
|
||||
height,
|
||||
left: Number.isFinite(bounds.left) ? bounds.left : 0,
|
||||
top: Number.isFinite(bounds.top) ? bounds.top : 0,
|
||||
width,
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number) {
|
||||
return Math.min(Math.max(value, minimum), maximum)
|
||||
}
|
||||
|
||||
/** 固定导航四角共用等半径;读取计算后的像素值与CSS轮廓保持一致。 */
|
||||
function readComputedRadius(element: HTMLElement) {
|
||||
const radius = Number.parseFloat(window.getComputedStyle(element).borderTopLeftRadius)
|
||||
return Number.isFinite(radius) ? Math.max(0, radius) : 0
|
||||
}
|
||||
|
||||
function readNormalizedClipRect(element: HTMLElement, backplate: BackplateBounds) {
|
||||
const bounds = element.getBoundingClientRect()
|
||||
const left = Number.isFinite(bounds.left) ? bounds.left : 0
|
||||
const top = Number.isFinite(bounds.top) ? bounds.top : 0
|
||||
const width = Number.isFinite(bounds.width) ? Math.max(0, bounds.width) : 0
|
||||
const height = Number.isFinite(bounds.height) ? Math.max(0, bounds.height) : 0
|
||||
const right = Number.isFinite(bounds.right) ? bounds.right : left + width
|
||||
const bottom = Number.isFinite(bounds.bottom) ? bounds.bottom : top + height
|
||||
const visibleLeft = clamp(Math.min(left, right) - backplate.left, 0, backplate.width)
|
||||
const visibleTop = clamp(Math.min(top, bottom) - backplate.top, 0, backplate.height)
|
||||
const visibleRight = clamp(Math.max(left, right) - backplate.left, 0, backplate.width)
|
||||
const visibleBottom = clamp(Math.max(top, bottom) - backplate.top, 0, backplate.height)
|
||||
const visibleWidth = visibleRight - visibleLeft
|
||||
const visibleHeight = visibleBottom - visibleTop
|
||||
|
||||
if (visibleWidth <= 0 || visibleHeight <= 0) return null
|
||||
|
||||
const radius = readComputedRadius(element)
|
||||
const normalizedWidth = visibleWidth / backplate.width
|
||||
const normalizedHeight = visibleHeight / backplate.height
|
||||
|
||||
return {
|
||||
height: normalizedHeight,
|
||||
rx: clamp(radius / backplate.width, 0, normalizedWidth / 2),
|
||||
ry: clamp(radius / backplate.height, 0, normalizedHeight / 2),
|
||||
width: normalizedWidth,
|
||||
x: visibleLeft / backplate.width,
|
||||
y: visibleTop / backplate.height,
|
||||
} satisfies NormalizedClipRect
|
||||
}
|
||||
|
||||
function bindGeometrySurface(surface: GeometrySurface, element: HTMLElement | null) {
|
||||
const previousElement = observedElements[surface]
|
||||
if (previousElement === element) return
|
||||
|
||||
if (resizeObserver && previousElement) resizeObserver.unobserve(previousElement)
|
||||
const previousHandler = transitionHandlers[surface]
|
||||
if (previousElement && previousHandler) {
|
||||
previousElement.removeEventListener('transitionrun', previousHandler)
|
||||
previousElement.removeEventListener('transitionend', previousHandler)
|
||||
previousElement.removeEventListener('transitioncancel', previousHandler)
|
||||
}
|
||||
|
||||
observedElements[surface] = element
|
||||
transitionHandlers[surface] = null
|
||||
if (!element) return
|
||||
|
||||
resizeObserver?.observe(element)
|
||||
const transitionHandler: EventListener = () => scheduleGeometrySync()
|
||||
transitionHandlers[surface] = transitionHandler
|
||||
element.addEventListener('transitionrun', transitionHandler)
|
||||
element.addEventListener('transitionend', transitionHandler)
|
||||
element.addEventListener('transitioncancel', transitionHandler)
|
||||
}
|
||||
|
||||
function observeStateSources() {
|
||||
if (!stateObserver) return
|
||||
|
||||
stateObserver.disconnect()
|
||||
stateObserver.observe(document.documentElement, {
|
||||
attributeFilter: GEOMETRY_ATTRIBUTE_FILTER,
|
||||
attributes: true,
|
||||
})
|
||||
if (document.body) {
|
||||
stateObserver.observe(document.body, {
|
||||
attributeFilter: GEOMETRY_ATTRIBUTE_FILTER,
|
||||
attributes: true,
|
||||
})
|
||||
}
|
||||
if (observedShell) {
|
||||
stateObserver.observe(observedShell, {
|
||||
attributeFilter: GEOMETRY_ATTRIBUTE_FILTER,
|
||||
attributes: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function refreshObservedElements() {
|
||||
const shell = getLayoutShell()
|
||||
if (shell !== observedShell) {
|
||||
observedShell = shell
|
||||
observeStateSources()
|
||||
}
|
||||
|
||||
bindGeometrySurface('sidebar', shell?.querySelector<HTMLElement>('.layout-vertical-nav:not(.overlay-nav)') ?? null)
|
||||
bindGeometrySurface('navbar', shell?.querySelector<HTMLElement>('.layout-navbar') ?? null)
|
||||
}
|
||||
|
||||
function applyGeometry(nextRects: NormalizedClipRect[]) {
|
||||
const nextKey = JSON.stringify(nextRects)
|
||||
if (nextKey === lastGeometryKey) return
|
||||
|
||||
lastGeometryKey = nextKey
|
||||
clipRects.value = nextRects
|
||||
}
|
||||
|
||||
function syncGeometry() {
|
||||
if (!isMounted) return
|
||||
|
||||
refreshObservedElements()
|
||||
const shell = observedShell
|
||||
const backplate = readBackplateBounds()
|
||||
const sidebar = observedElements.sidebar
|
||||
const navbar = observedElements.navbar
|
||||
|
||||
if (!shell || !backplate || !isConnectedDesktopShell(shell) || !sidebar || !navbar) {
|
||||
applyGeometry([])
|
||||
return
|
||||
}
|
||||
|
||||
const nextRects = GEOMETRY_SURFACES.map(surface =>
|
||||
readNormalizedClipRect(observedElements[surface] as HTMLElement, backplate),
|
||||
)
|
||||
if (nextRects.some(rect => rect === null)) {
|
||||
applyGeometry([])
|
||||
return
|
||||
}
|
||||
|
||||
applyGeometry(nextRects as NormalizedClipRect[])
|
||||
}
|
||||
|
||||
function scheduleGeometrySync() {
|
||||
if (geometrySyncQueued) return
|
||||
|
||||
geometrySyncQueued = true
|
||||
queueMicrotask(() => {
|
||||
geometrySyncQueued = false
|
||||
syncGeometry()
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => props.isOverlayNav, scheduleGeometrySync, { flush: 'sync' })
|
||||
|
||||
onMounted(() => {
|
||||
isMounted = true
|
||||
stateObserver = new MutationObserver(scheduleGeometrySync)
|
||||
resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(() => scheduleGeometrySync())
|
||||
usesResizeFallback = resizeObserver === null
|
||||
if (usesResizeFallback) window.addEventListener('resize', scheduleGeometrySync, { passive: true })
|
||||
else if (mainBackplateRef.value) resizeObserver?.observe(mainBackplateRef.value)
|
||||
|
||||
refreshObservedElements()
|
||||
observeStateSources()
|
||||
syncGeometry()
|
||||
void nextTick(syncGeometry)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
isMounted = false
|
||||
stateObserver?.disconnect()
|
||||
stateObserver = null
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
if (usesResizeFallback) window.removeEventListener('resize', scheduleGeometrySync)
|
||||
usesResizeFallback = false
|
||||
|
||||
for (const surface of GEOMETRY_SURFACES) bindGeometrySurface(surface, null)
|
||||
observedShell = null
|
||||
geometrySyncQueued = false
|
||||
lastGeometryKey = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="mainBackplateRef"
|
||||
class="glass-fixed-shell-backplate glass-fixed-shell-backplate--main"
|
||||
data-backplate-surface="main"
|
||||
:style="transitionStyle"
|
||||
:style="mainBackplateStyle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
@@ -46,6 +322,24 @@ const transitionStyle = computed(() => ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg class="glass-fixed-shell-backplate__geometry" width="0" height="0" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<clipPath :id="clipPathId" clipPathUnits="objectBoundingBox">
|
||||
<rect
|
||||
v-for="(rect, index) in clipRects"
|
||||
:key="GEOMETRY_SURFACES[index]"
|
||||
:data-clip-surface="GEOMETRY_SURFACES[index]"
|
||||
:height="rect.height"
|
||||
:rx="rect.rx"
|
||||
:ry="rect.ry"
|
||||
:width="rect.width"
|
||||
:x="rect.x"
|
||||
:y="rect.y"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
v-if="isOverlayNav"
|
||||
class="glass-fixed-shell-backplate glass-fixed-shell-backplate--overlay-nav"
|
||||
@@ -89,6 +383,12 @@ const transitionStyle = computed(() => ({
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.glass-fixed-shell-backplate__geometry {
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.glass-fixed-shell-backplate--main {
|
||||
--glass-fixed-shell-nav-inline-size: #{variables.$layout-vertical-nav-width};
|
||||
|
||||
|
||||
@@ -156,7 +156,9 @@ function isRefractionActive(surface: NavigationSurface) {
|
||||
|
||||
if (surface === 'navbar') {
|
||||
return (
|
||||
(shell.dataset.shellMode === 'desktop' && !shell.classList.contains('layout-horizontal-nav-active')) ||
|
||||
(shell.dataset.shellMode === 'desktop' &&
|
||||
!shell.classList.contains('layout-horizontal-nav-active') &&
|
||||
!shell.classList.contains('layout-window-controls-overlay-shell')) ||
|
||||
(shell.classList.contains('layout-navbar-floating-eligible') &&
|
||||
shell.classList.contains('layout-navbar-away-from-top'))
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import GlassFixedShellBackplate from '@/components/theme/GlassFixedShellBackplate.vue'
|
||||
import type { GlassFixedShellBackplateLayer } from '@/composables/useGlassFixedShellBackplate'
|
||||
|
||||
@@ -26,9 +27,146 @@ const initialLayers: readonly GlassFixedShellBackplateLayer[] = [
|
||||
},
|
||||
]
|
||||
|
||||
interface TestRect {
|
||||
bottom: number
|
||||
height: number
|
||||
left: number
|
||||
right: number
|
||||
top: number
|
||||
width: number
|
||||
toJSON: () => Record<string, never>
|
||||
}
|
||||
|
||||
interface BackplateMountOptions {
|
||||
[key: string]: unknown
|
||||
attachTo?: Element
|
||||
props: {
|
||||
isOverlayNav: boolean
|
||||
isOverlayNavActive: boolean
|
||||
layers: readonly GlassFixedShellBackplateLayer[]
|
||||
transitionDurationMs: number
|
||||
}
|
||||
}
|
||||
|
||||
const mountedWrappers: Array<ReturnType<typeof mount>> = []
|
||||
let resizeCallback: ResizeObserverCallback | undefined
|
||||
let resizeDisconnect: ReturnType<typeof vi.fn> | undefined
|
||||
let backplateRect: TestRect
|
||||
let sidebarRect: TestRect
|
||||
let navbarRect: TestRect
|
||||
|
||||
function createRect(left: number, top: number, width: number, height: number): TestRect {
|
||||
return {
|
||||
bottom: top + height,
|
||||
height,
|
||||
left,
|
||||
right: left + width,
|
||||
top,
|
||||
toJSON: () => ({}),
|
||||
width,
|
||||
}
|
||||
}
|
||||
|
||||
function mountBackplate(options: BackplateMountOptions) {
|
||||
const wrapper = mount(GlassFixedShellBackplate, options)
|
||||
mountedWrappers.push(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function mountConnectedShell(horizontal = false) {
|
||||
const shell = document.createElement('div')
|
||||
shell.className = `layout-wrapper${horizontal ? ' layout-horizontal-nav-active' : ''}`
|
||||
shell.dataset.shellMode = 'desktop'
|
||||
shell.dataset.shellNavbarAttachment = horizontal ? 'theme-qualified' : 'connected'
|
||||
|
||||
const sidebar = document.createElement('aside')
|
||||
sidebar.className = 'layout-vertical-nav'
|
||||
const navbar = document.createElement('header')
|
||||
navbar.className = 'layout-navbar'
|
||||
shell.append(sidebar, navbar)
|
||||
document.body.append(shell)
|
||||
|
||||
return {
|
||||
navbar,
|
||||
shell,
|
||||
sidebar,
|
||||
wrapper: mountBackplate({
|
||||
attachTo: shell,
|
||||
props: {
|
||||
isOverlayNav: false,
|
||||
isOverlayNavActive: false,
|
||||
layers: initialLayers,
|
||||
transitionDurationMs: 1500,
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async function settleGeometry() {
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('GlassFixedShellBackplate', () => {
|
||||
beforeEach(() => {
|
||||
resizeCallback = undefined
|
||||
resizeDisconnect = vi.fn()
|
||||
backplateRect = createRect(5, 3, 1185, 790)
|
||||
sidebarRect = createRect(13, 11, 252, 774)
|
||||
navbarRect = createRect(273, 11, 909, 72)
|
||||
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback
|
||||
}
|
||||
|
||||
observe() {}
|
||||
|
||||
unobserve() {}
|
||||
|
||||
disconnect() {
|
||||
resizeDisconnect?.()
|
||||
}
|
||||
},
|
||||
)
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.classList.contains('glass-fixed-shell-backplate--main')) return backplateRect as DOMRect
|
||||
if (this.classList.contains('layout-vertical-nav')) return sidebarRect as DOMRect
|
||||
if (this.classList.contains('layout-navbar')) return navbarRect as DOMRect
|
||||
return createRect(0, 0, 0, 0) as DOMRect
|
||||
})
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation(
|
||||
element =>
|
||||
({
|
||||
borderBottomLeftRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||
borderBottomRightRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||
borderEndEndRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||
borderEndStartRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||
borderRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||
borderStartEndRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||
borderStartStartRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||
borderTopLeftRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||
borderTopRightRadius: element instanceof HTMLElement ? '16px' : '0px',
|
||||
getPropertyValue: (property: string) => (property.includes('radius') ? '16px' : ''),
|
||||
}) as unknown as CSSStyleDeclaration,
|
||||
)
|
||||
document.documentElement.dataset.theme = 'glass'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mountedWrappers.splice(0)) wrapper.unmount()
|
||||
document.querySelectorAll('.layout-wrapper').forEach(element => element.remove())
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('renders the App-owned slots once for the shared desktop shell', () => {
|
||||
const wrapper = mount(GlassFixedShellBackplate, {
|
||||
const wrapper = mountBackplate({
|
||||
props: {
|
||||
isOverlayNav: false,
|
||||
isOverlayNavActive: false,
|
||||
@@ -51,7 +189,7 @@ describe('GlassFixedShellBackplate', () => {
|
||||
})
|
||||
|
||||
it('preserves slot nodes while their active and previous roles swap', async () => {
|
||||
const wrapper = mount(GlassFixedShellBackplate, {
|
||||
const wrapper = mountBackplate({
|
||||
props: {
|
||||
isOverlayNav: false,
|
||||
isOverlayNavActive: false,
|
||||
@@ -76,7 +214,7 @@ describe('GlassFixedShellBackplate', () => {
|
||||
})
|
||||
|
||||
it('adds a separately clipped surface only for mobile overlay navigation', () => {
|
||||
const wrapper = mount(GlassFixedShellBackplate, {
|
||||
const wrapper = mountBackplate({
|
||||
props: {
|
||||
isOverlayNav: true,
|
||||
isOverlayNavActive: true,
|
||||
@@ -90,4 +228,71 @@ describe('GlassFixedShellBackplate', () => {
|
||||
expect(overlay.classes()).toContain('is-visible')
|
||||
expect(overlay.findAll('[data-backplate-slot]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('maps both connected surfaces to backplate-relative objectBoundingBox geometry', async () => {
|
||||
const { wrapper } = mountConnectedShell()
|
||||
await settleGeometry()
|
||||
|
||||
const main = wrapper.get('[data-backplate-surface="main"]')
|
||||
const mainElement = main.element as HTMLElement
|
||||
const clipPath = wrapper.get('clipPath')
|
||||
const rects = clipPath.findAll('rect')
|
||||
|
||||
expect(clipPath.attributes('clipPathUnits')).toBe('objectBoundingBox')
|
||||
expect(rects).toHaveLength(2)
|
||||
expect(mainElement.style.clipPath).toMatch(/^url\(#glass-fixed-shell-clip-/u)
|
||||
expect(Number(rects[0].attributes('x'))).toBeCloseTo(8 / 1185, 8)
|
||||
expect(Number(rects[0].attributes('y'))).toBeCloseTo(8 / 790, 8)
|
||||
expect(Number(rects[0].attributes('width'))).toBeCloseTo(252 / 1185, 8)
|
||||
expect(Number(rects[0].attributes('height'))).toBeCloseTo(774 / 790, 8)
|
||||
expect(Number(rects[0].attributes('rx'))).toBeCloseTo(16 / 1185, 8)
|
||||
expect(Number(rects[0].attributes('ry'))).toBeCloseTo(16 / 790, 8)
|
||||
expect(Number(rects[1].attributes('x'))).toBeCloseTo(268 / 1185, 8)
|
||||
expect(Number(rects[1].attributes('y'))).toBeCloseTo(8 / 790, 8)
|
||||
expect(Number(rects[1].attributes('width'))).toBeCloseTo(909 / 1185, 8)
|
||||
expect(Number(rects[1].attributes('height'))).toBeCloseTo(72 / 790, 8)
|
||||
expect(Number(rects[1].attributes('rx'))).toBeCloseTo(16 / 1185, 8)
|
||||
expect(Number(rects[1].attributes('ry'))).toBeCloseTo(16 / 790, 8)
|
||||
expect(wrapper.findAll('[data-backplate-surface="main"]')).toHaveLength(1)
|
||||
expect(wrapper.findAll('[data-backplate-slot]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not activate the connected clip for horizontal navigation', async () => {
|
||||
const { wrapper } = mountConnectedShell(true)
|
||||
await settleGeometry()
|
||||
|
||||
expect((wrapper.get('[data-backplate-surface="main"]').element as HTMLElement).style.clipPath).toBe('')
|
||||
expect(wrapper.findAll('clipPath rect')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('refreshes dimensions and disconnects the resize observer on unmount', async () => {
|
||||
const { wrapper } = mountConnectedShell()
|
||||
await settleGeometry()
|
||||
|
||||
backplateRect = createRect(5, 3, 1180, 790)
|
||||
sidebarRect = createRect(13, 11, 60, 774)
|
||||
navbarRect = createRect(273, 11, 904, 96)
|
||||
resizeCallback?.([], {} as ResizeObserver)
|
||||
await settleGeometry()
|
||||
|
||||
const rects = wrapper.findAll('clipPath rect')
|
||||
expect(Number(rects[0].attributes('width'))).toBeCloseTo(60 / 1180, 8)
|
||||
expect(Number(rects[1].attributes('x'))).toBeCloseTo(268 / 1180, 8)
|
||||
expect(Number(rects[1].attributes('height'))).toBeCloseTo(96 / 790, 8)
|
||||
|
||||
wrapper.unmount()
|
||||
expect(resizeDisconnect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses window resize when ResizeObserver is unavailable', async () => {
|
||||
vi.stubGlobal('ResizeObserver', undefined)
|
||||
const { wrapper } = mountConnectedShell()
|
||||
await settleGeometry()
|
||||
|
||||
sidebarRect = createRect(13, 11, 60, 774)
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
await settleGeometry()
|
||||
|
||||
expect(Number(wrapper.find('clipPath rect').attributes('width'))).toBeCloseTo(60 / 1185, 8)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use '@configured-variables' as variables;
|
||||
|
||||
// V3 表面按信息角色统一材料;只改变承载层,不改变图表、业务数据与交互命中区。
|
||||
@mixin surfaces {
|
||||
html[data-theme='glass'] {
|
||||
@@ -106,6 +108,19 @@
|
||||
border-inline-start: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.layout-page-content .site-card .border-t .text-medium-emphasis {
|
||||
// 上传/下载是核心读数,信息权重高于网址和能力标识。
|
||||
color: rgb(var(--v-theme-on-surface)) !important;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.layout-page-content .site-card h3,
|
||||
.layout-page-content .plugin-card__banner .v-card-title {
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.layout-page-content .plugin-card__banner {
|
||||
--plugin-card-banner-scrim: linear-gradient(rgba(23, 27, 32, 0.035), rgba(23, 27, 32, 0.1));
|
||||
--plugin-card-banner-tint: linear-gradient(
|
||||
@@ -140,6 +155,42 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 固定导航保留布局占位,玻璃实体在占位内留出空气,采样几何仍由真实元素提供。
|
||||
html[data-theme='glass']
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper[data-shell-mode='desktop']:not(
|
||||
.layout-horizontal-nav-active,
|
||||
.layout-window-controls-overlay-shell
|
||||
) {
|
||||
--glass-v3-nav-reserved-width: #{variables.$layout-vertical-nav-width};
|
||||
|
||||
&.layout-vertical-nav-collapsed {
|
||||
--glass-v3-nav-reserved-width: #{variables.$layout-vertical-nav-collapsed-width};
|
||||
}
|
||||
|
||||
.layout-vertical-nav {
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--glass-v3-shadow) !important;
|
||||
block-size: calc(100% - 16px);
|
||||
inline-size: calc(var(--glass-v3-nav-reserved-width) - 8px) !important;
|
||||
inset-block-start: 8px;
|
||||
inset-inline-start: 8px;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
&.layout-vertical-nav-collapsed .layout-vertical-nav.hovered {
|
||||
inline-size: calc(#{variables.$layout-vertical-nav-width} - 8px) !important;
|
||||
}
|
||||
|
||||
.layout-navbar {
|
||||
border-radius: 16px !important;
|
||||
inline-size: calc(100% - var(--glass-v3-nav-reserved-width) - 16px) !important;
|
||||
inset-block-start: 8px !important;
|
||||
inset-inline-start: calc(var(--glass-v3-nav-reserved-width) + 8px) !important;
|
||||
overflow: clip;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
html[data-theme='glass'] body[data-theme='glass'] {
|
||||
.layout-page-content
|
||||
|
||||
@@ -77,6 +77,8 @@ describe('createGlassNavbarDisplacementField', () => {
|
||||
{ width: 127, height: 64, radius: 32 },
|
||||
{ width: 260, height: 800, radius: 0 },
|
||||
{ width: 68, height: 862, radius: 0 },
|
||||
{ width: 252, height: 846, radius: 16 },
|
||||
{ width: 60, height: 846, radius: 16 },
|
||||
])('keeps two-dimensional sampling forward and inside the image for $width x $height r$radius', geometry => {
|
||||
for (const deformation of [0, 48, 100])
|
||||
for (const translation of [0, 48, 100])
|
||||
|
||||
Reference in New Issue
Block a user