wip bsky client for the web & android
bbell.vt3e.cat
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>