wip bsky client for the web & android bbell.vt3e.cat
at main 218 lines 4.6 kB view raw
1<script lang="ts" setup> 2import { computed, ref, watch, defineAsyncComponent, type Component } from 'vue' 3import { useNavigationStore } from '@/stores/navigation' 4import { pages, type StackRootNames } from '@/router' 5import { useEnvironmentStore } from '@/stores/environment' 6 7const props = defineProps<{ tab: StackRootNames }>() 8const nav = useNavigationStore() 9const env = useEnvironmentStore() 10 11const stack = computed(() => nav.stacks[props.tab]) 12 13const registry: Record<string, Component> = pages.reduce( 14 (acc, page) => { 15 const comp = page.component 16 acc[page.name] = 17 typeof comp === 'function' 18 ? defineAsyncComponent({ 19 loader: comp as unknown as () => Promise<Component>, 20 }) 21 : comp 22 23 return acc 24 }, 25 {} as Record<string, Component>, 26) 27 28const isAnimating = ref(false) 29const animationType = ref<'push' | 'pop' | null>(null) 30const previousStackLength = ref(stack.value?.length || 0) 31 32const visualTopIndex = computed(() => { 33 if (nav.pendingPop?.tab === props.tab) return stack.value?.length ? stack.value.length - 2 : 0 34 return stack.value?.length ? stack.value.length - 1 : 0 35}) 36 37const shouldAnimate = computed(() => { 38 return env.isMobile && !env.prefersReducedMotion 39}) 40 41watch( 42 () => stack.value?.length, 43 (newLength, oldLength) => { 44 if (!newLength || !oldLength) return 45 46 if (nav.activeTab !== props.tab) { 47 previousStackLength.value = newLength 48 return 49 } 50 51 if (newLength > oldLength && shouldAnimate.value) { 52 animationType.value = 'push' 53 isAnimating.value = true 54 55 setTimeout(() => { 56 isAnimating.value = false 57 animationType.value = null 58 }, 300) 59 } 60 61 previousStackLength.value = newLength 62 }, 63) 64 65watch( 66 () => nav.pendingPop, 67 (pendingPop) => { 68 if (!pendingPop || pendingPop.tab !== props.tab || nav.activeTab !== props.tab) { 69 return 70 } 71 72 if (!shouldAnimate.value) { 73 nav.completePop() 74 return 75 } 76 77 animationType.value = 'pop' 78 isAnimating.value = true 79 80 setTimeout(() => { 81 isAnimating.value = false 82 animationType.value = null 83 nav.completePop() 84 }, 300) 85 }, 86 { immediate: true }, 87) 88</script> 89 90<template> 91 <div 92 :data-tab="props.tab" 93 :id="`tabpanel-${props.tab}`" 94 :aria-hidden="nav.activeTab !== props.tab" 95 :aria-labelledby="`tab-${props.tab}`" 96 class="tab-stack" 97 aria-role="tabpanel" 98 > 99 <template v-if="stack"> 100 <div 101 v-for="(entry, index) in stack" 102 :key="entry.id" 103 :class="[ 104 'stack-page', 105 { 106 'is-visible': 107 index === stack.length - 1 || 108 index === visualTopIndex || 109 index === visualTopIndex - 1, 110 111 'is-visual-top': index === visualTopIndex, 112 'is-below-visual-top': index === visualTopIndex - 1, 113 'is-animating': isAnimating, 114 'push-enter': isAnimating && animationType === 'push' && index === stack.length - 1, 115 'pop-exit': isAnimating && animationType === 'pop' && index === stack.length - 1, 116 }, 117 ]" 118 :data-entry-id="entry.id" 119 :data-index="index" 120 :inert="index !== visualTopIndex" 121 > 122 <Suspense> 123 <template #default> 124 <component :is="registry[entry.page]" v-bind="entry.props" :routeName="entry.page" /> 125 </template> 126 <template #fallback> 127 <div class="page-loading" aria-hidden="true"></div> 128 </template> 129 </Suspense> 130 </div> 131 </template> 132 </div> 133</template> 134 135<style scoped> 136.tab-stack { 137 position: absolute; 138 inset: 0; 139} 140 141.stack-page { 142 position: absolute; 143 inset: 0; 144 box-sizing: border-box; 145 146 display: none; 147 transform: translateY(100%); 148 transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1); 149 150 &.no-motion { 151 display: block; 152 transform: none !important; 153 animation: none !important; 154 transition: none !important; 155 } 156 157 &.is-visible { 158 display: block; 159 } 160 161 &.is-visual-top { 162 transform: translateY(0); 163 z-index: 10; 164 } 165 166 &.is-below-visual-top { 167 transform: translateY(0); 168 z-index: 9; 169 } 170 171 &.push-enter { 172 transform: translateY(9%); 173 z-index: 11; 174 animation: slideUp 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards; 175 } 176 177 &.pop-exit { 178 transform: translateY(0); 179 z-index: 11; 180 animation: slideDown 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards; 181 } 182} 183 184@keyframes slideUp { 185 from { 186 transform: translateY(20%); 187 opacity: 0; 188 } 189 to { 190 transform: translateY(0); 191 opacity: 1; 192 } 193} 194 195@keyframes slideDown { 196 from { 197 transform: translateY(0); 198 opacity: 1; 199 } 200 to { 201 transform: translateY(20%); 202 opacity: 0; 203 } 204} 205 206@media (prefers-reduced-motion: reduce) { 207 .stack-page { 208 transition: none; 209 } 210 211 .stack-page.push-enter, 212 .stack-page.pop-exit { 213 animation: none; 214 transform: translateY(0); 215 opacity: 1; 216 } 217} 218</style>