mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-04 23:18:44 +08:00
fix(glass): stabilize frosted fixed shells (#602)
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
<script lang="ts" setup>
|
||||
import type { GlassFixedShellBackplateLayer } from '@/composables/useGlassFixedShellBackplate'
|
||||
|
||||
interface Props {
|
||||
/** 移动端 overlay 导航当前是否可见。 */
|
||||
isOverlayNavActive: boolean
|
||||
/** 当前布局是否使用移动端 overlay 导航。 */
|
||||
isOverlayNav: boolean
|
||||
/** App 壁纸状态机提供的稳定双槽位。 */
|
||||
layers: readonly GlassFixedShellBackplateLayer[]
|
||||
/** 与全局壁纸事务一致的交叉淡化时长。 */
|
||||
transitionDurationMs: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const transitionStyle = computed(() => ({
|
||||
'--glass-fixed-shell-transition-duration': `${Math.max(0, props.transitionDurationMs)}ms`,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="glass-fixed-shell-backplate glass-fixed-shell-backplate--main"
|
||||
data-backplate-surface="main"
|
||||
:style="transitionStyle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
v-for="layer in layers"
|
||||
:key="layer.key"
|
||||
class="glass-fixed-shell-backplate__layer"
|
||||
:class="`is-${layer.role}`"
|
||||
:data-backplate-slot="layer.key"
|
||||
>
|
||||
<div class="glass-fixed-shell-backplate__wallpaper" :style="layer.style" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isOverlayNav"
|
||||
class="glass-fixed-shell-backplate glass-fixed-shell-backplate--overlay-nav"
|
||||
:class="{ 'is-visible': isOverlayNavActive }"
|
||||
data-backplate-surface="overlay-nav"
|
||||
:style="transitionStyle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
v-for="layer in layers"
|
||||
:key="layer.key"
|
||||
class="glass-fixed-shell-backplate__layer"
|
||||
:class="`is-${layer.role}`"
|
||||
:data-backplate-slot="layer.key"
|
||||
>
|
||||
<div class="glass-fixed-shell-backplate__wallpaper" :style="layer.style" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@use '@configured-variables' as variables;
|
||||
@use '@layouts/styles/mixins';
|
||||
|
||||
.glass-fixed-shell-backplate {
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
contain: strict;
|
||||
inset: 0;
|
||||
isolation: isolate;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.glass-fixed-shell-backplate--main {
|
||||
--glass-fixed-shell-nav-inline-size: #{variables.$layout-vertical-nav-width};
|
||||
|
||||
z-index: variables.$layout-vertical-nav-layout-navbar-z-index - 1;
|
||||
clip-path: polygon(
|
||||
0 0,
|
||||
calc(100% - 0.5rem) 0,
|
||||
calc(100% - 0.5rem) var(--layout-navbar-block-size),
|
||||
var(--glass-fixed-shell-nav-inline-size) var(--layout-navbar-block-size),
|
||||
var(--glass-fixed-shell-nav-inline-size) 100%,
|
||||
0 100%
|
||||
);
|
||||
transition: clip-path 0.25s ease-in-out;
|
||||
}
|
||||
|
||||
.layout-wrapper.layout-vertical-nav-collapsed > .glass-fixed-shell-backplate--main {
|
||||
--glass-fixed-shell-nav-inline-size: #{variables.$layout-vertical-nav-collapsed-width};
|
||||
}
|
||||
|
||||
.layout-wrapper:is(.layout-horizontal-nav-active, .layout-overlay-nav) > .glass-fixed-shell-backplate--main {
|
||||
clip-path: inset(0 0 calc(100% - var(--layout-navbar-block-size)) 0);
|
||||
}
|
||||
|
||||
[dir='rtl'] .layout-wrapper > .glass-fixed-shell-backplate--main {
|
||||
clip-path: polygon(
|
||||
0.5rem 0,
|
||||
100% 0,
|
||||
100% 100%,
|
||||
calc(100% - var(--glass-fixed-shell-nav-inline-size)) 100%,
|
||||
calc(100% - var(--glass-fixed-shell-nav-inline-size)) var(--layout-navbar-block-size),
|
||||
0.5rem var(--layout-navbar-block-size)
|
||||
);
|
||||
}
|
||||
|
||||
[dir='rtl']
|
||||
.layout-wrapper:is(.layout-horizontal-nav-active, .layout-overlay-nav)
|
||||
> .glass-fixed-shell-backplate--main {
|
||||
clip-path: inset(0 0 calc(100% - var(--layout-navbar-block-size)) 0);
|
||||
}
|
||||
|
||||
.glass-fixed-shell-backplate--overlay-nav {
|
||||
z-index: variables.$layout-vertical-nav-z-index - 1;
|
||||
clip-path: inset(0 100% 0 0);
|
||||
transition: clip-path 0.25s ease-in-out;
|
||||
|
||||
&.is-visible {
|
||||
clip-path: inset(0 calc(100% - #{variables.$layout-vertical-nav-width}) 0 0);
|
||||
}
|
||||
|
||||
@include mixins.rtl {
|
||||
clip-path: inset(0 0 0 100%);
|
||||
|
||||
&.is-visible {
|
||||
clip-path: inset(0 0 0 calc(100% - #{variables.$layout-vertical-nav-width}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.glass-fixed-shell-backplate__layer {
|
||||
position: absolute;
|
||||
filter: var(--glass-fixed-shell-backplate-filter);
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
transition: opacity var(--glass-fixed-shell-transition-duration, 1500ms) ease;
|
||||
will-change: opacity;
|
||||
|
||||
&.is-active {
|
||||
z-index: 2;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
&.is-previous {
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-fixed-shell-backplate__wallpaper {
|
||||
position: absolute;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
filter: brightness(var(--glass-wallpaper-brightness, 0.82)) saturate(0.9);
|
||||
inset: 0;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
background: linear-gradient(rgba(6, 10, 19, 24%) 0%, rgba(6, 10, 19, 48%) 100%), rgba(11, 19, 34, 8%);
|
||||
content: '';
|
||||
inset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.glass-fixed-shell-backplate,
|
||||
.glass-fixed-shell-backplate__layer {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.glass-fixed-shell-backplate {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import GlassFixedShellBackplate from '@/components/theme/GlassFixedShellBackplate.vue'
|
||||
import type { GlassFixedShellBackplateLayer } from '@/composables/useGlassFixedShellBackplate'
|
||||
|
||||
const initialLayers: readonly GlassFixedShellBackplateLayer[] = [
|
||||
{
|
||||
key: 'front',
|
||||
role: 'active',
|
||||
style: {
|
||||
'backgroundImage': 'url(/wallpaper-current.jpg)',
|
||||
'--glass-wallpaper-brightness': '0.82',
|
||||
},
|
||||
url: '/wallpaper-current.jpg',
|
||||
},
|
||||
{
|
||||
key: 'back',
|
||||
role: 'standby',
|
||||
style: {
|
||||
'backgroundImage': 'url(/wallpaper-next.jpg)',
|
||||
'--glass-wallpaper-brightness': '0.76',
|
||||
},
|
||||
url: '/wallpaper-next.jpg',
|
||||
},
|
||||
]
|
||||
|
||||
describe('GlassFixedShellBackplate', () => {
|
||||
it('renders the App-owned slots once for the shared desktop shell', () => {
|
||||
const wrapper = mount(GlassFixedShellBackplate, {
|
||||
props: {
|
||||
isOverlayNav: false,
|
||||
isOverlayNavActive: false,
|
||||
layers: initialLayers,
|
||||
transitionDurationMs: 1500,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('[data-backplate-surface="main"] [data-backplate-slot]')).toHaveLength(2)
|
||||
expect(wrapper.find('[data-backplate-slot="front"]').classes()).toContain('is-active')
|
||||
expect(wrapper.find('[data-backplate-slot="back"]').classes()).toContain('is-standby')
|
||||
expect(
|
||||
(wrapper.find('[data-backplate-slot="front"] > div').element as HTMLElement).style.backgroundImage,
|
||||
).toContain('/wallpaper-current.jpg')
|
||||
expect(wrapper.find('[data-backplate-surface="main"]').attributes('style')).toContain(
|
||||
'--glass-fixed-shell-transition-duration: 1500ms',
|
||||
)
|
||||
expect(wrapper.find('[data-backplate-surface="overlay-nav"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves slot nodes while their active and previous roles swap', async () => {
|
||||
const wrapper = mount(GlassFixedShellBackplate, {
|
||||
props: {
|
||||
isOverlayNav: false,
|
||||
isOverlayNavActive: false,
|
||||
layers: initialLayers,
|
||||
transitionDurationMs: 1500,
|
||||
},
|
||||
})
|
||||
const frontSlot = wrapper.find('[data-backplate-surface="main"] [data-backplate-slot="front"]').element
|
||||
const backSlot = wrapper.find('[data-backplate-surface="main"] [data-backplate-slot="back"]').element
|
||||
|
||||
await wrapper.setProps({
|
||||
layers: [
|
||||
{ ...initialLayers[0], role: 'previous' },
|
||||
{ ...initialLayers[1], role: 'active' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-backplate-surface="main"] [data-backplate-slot="front"]').element).toBe(frontSlot)
|
||||
expect(wrapper.find('[data-backplate-surface="main"] [data-backplate-slot="back"]').element).toBe(backSlot)
|
||||
expect(wrapper.find('[data-backplate-slot="front"]').classes()).toContain('is-previous')
|
||||
expect(wrapper.find('[data-backplate-slot="back"]').classes()).toContain('is-active')
|
||||
})
|
||||
|
||||
it('adds a separately clipped surface only for mobile overlay navigation', () => {
|
||||
const wrapper = mount(GlassFixedShellBackplate, {
|
||||
props: {
|
||||
isOverlayNav: true,
|
||||
isOverlayNavActive: true,
|
||||
layers: initialLayers,
|
||||
transitionDurationMs: 1500,
|
||||
},
|
||||
})
|
||||
|
||||
const overlay = wrapper.find('[data-backplate-surface="overlay-nav"]')
|
||||
expect(overlay.exists()).toBe(true)
|
||||
expect(overlay.classes()).toContain('is-visible')
|
||||
expect(overlay.findAll('[data-backplate-slot]')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user