wip bsky client for the web & android
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

chore: rervert toaststack

vi 398b9ac2 76a90b0f

+4 -317
+4 -317
src/components/UI/Toast/ToastStack.vue
··· 1 1 <script setup lang="ts"> 2 - import { ref, onBeforeUnmount, nextTick } from 'vue' 2 + import { ref } from 'vue' 3 3 4 4 import { 5 5 IconCheckRounded, ··· 24 24 type: ToastType 25 25 } 26 26 27 - /* Example toasts */ 28 27 const toasts = ref<Toast[]>([ 29 28 { 30 29 id: 1, ··· 37 36 type: ToastType.Error, 38 37 }, 39 38 ]) 40 - 41 - /* Configuration */ 42 - const AUTO_DISMISS_MS = 5000 43 - const SWIPE_CLICK_CANCEL_DISTANCE = 6 // px before we consider it a drag 44 - const SWIPE_REMOVAL_THRESHOLD_RATIO = 0.35 // percent of width 45 - const SWIPE_REMOVAL_MIN = 100 // px for threshold minimum 46 - const SWIPE_OUT_ANIM_MS = 200 47 - 48 - /* Per-toast runtime state tracked by id */ 49 - type DragState = { 50 - startX: number 51 - currentX: number 52 - dragging: boolean 53 - removing: boolean 54 - pointerId?: number 55 - width?: number 56 - transition?: string 57 - } 58 - const dragStates = ref<Record<number, DragState>>({}) 59 - 60 - /* Auto-dismiss timers */ 61 - const autoTimers = new Map<number, number>() 62 - 63 - /* Helper: start auto-dismiss for a toast */ 64 - function startAutoDismiss(id: number, duration = AUTO_DISMISS_MS) { 65 - clearAutoDismiss(id) 66 - const t = window.setTimeout(() => { 67 - // if currently being dragged, postpone until pointer up — don't start immediate removal 68 - const s = dragStates.value[id] 69 - if (s?.dragging) { 70 - // postpone: once pointerUp happens we'll restart a timer 71 - return 72 - } 73 - beginRemovingAnimated(id) 74 - }, duration) 75 - autoTimers.set(id, t) 76 - } 77 - 78 - /* Helper: clear auto-dismiss timer */ 79 - function clearAutoDismiss(id: number) { 80 - const existing = autoTimers.get(id) 81 - if (existing) { 82 - window.clearTimeout(existing) 83 - autoTimers.delete(id) 84 - } 85 - } 86 - 87 - /* Begin removal with swipe-out animation (we set removing state and animate translateX and opacity), 88 - then remove from the array after the animation duration. This is used for both clicks / auto-dismiss 89 - and swipe past threshold. */ 90 - function beginRemovingAnimated(id: number, direction?: number) { 91 - const state = dragStates.value[id] ?? { startX: 0, currentX: 0, dragging: false, removing: false } 92 - state.removing = true 93 - state.dragging = false 94 - // direction: -1 left, +1 right. If not provided, slide up and fade (translateY) - choose left by default 95 - if (typeof direction === 'number') { 96 - // slide off-screen horizontally in that direction 97 - state.currentX = direction > 0 ? window.innerWidth : -window.innerWidth 98 - state.transition = `transform ${SWIPE_OUT_ANIM_MS}ms ease, opacity ${SWIPE_OUT_ANIM_MS}ms ease` 99 - } else { 100 - // fallback: slide up a bit while fading 101 - state.transition = `transform ${SWIPE_OUT_ANIM_MS}ms ease, opacity ${SWIPE_OUT_ANIM_MS}ms ease` 102 - // Using translateY to somewhat match other leave animations 103 - // We'll encode this by storing a special currentX === NaN to indicate translateY-out 104 - state.currentX = 0 105 - } 106 - dragStates.value[id] = state 107 - 108 - // clear auto-dismiss if any 109 - clearAutoDismiss(id) 110 - 111 - // wait for animation to finish then remove 112 - window.setTimeout(() => { 113 - removeToastById(id) 114 - }, SWIPE_OUT_ANIM_MS + 10) 115 - } 116 - 117 - /* Remove toast immediately and clear state/timers */ 118 - function removeToastById(id: number) { 119 - clearAutoDismiss(id) 120 - delete dragStates.value[id] 121 - const idx = toasts.value.findIndex((t) => t.id === id) 122 - if (idx !== -1) { 123 - toasts.value.splice(idx, 1) 124 - } 125 - } 126 - 127 - /* Pointer handlers: 128 - - pointerdown: capture pointer, initialize drag state and clear auto-dismiss 129 - - pointermove: update currentX and set dragging flag 130 - - pointerup/pointercancel: decide whether to dismiss or reset 131 - */ 132 - function onPointerDown(id: number, e: PointerEvent) { 133 - // only left button or touch 134 - if (e.pointerType === 'mouse' && e.button !== 0) return 135 - 136 - const el = e.currentTarget as HTMLElement 137 - try { 138 - el.setPointerCapture(e.pointerId) 139 - } catch { 140 - // ignore if not supported / fails 141 - } 142 - 143 - clearAutoDismiss(id) 144 - const width = el.offsetWidth || 0 145 - dragStates.value[id] = { 146 - startX: e.clientX, 147 - currentX: 0, 148 - dragging: false, 149 - removing: false, 150 - pointerId: e.pointerId, 151 - width, 152 - transition: 'none', 153 - } 154 - } 155 - 156 - function onPointerMove(id: number, e: PointerEvent) { 157 - const s = dragStates.value[id] 158 - if (!s) return 159 - // ensure same pointer 160 - if (s.pointerId !== undefined && e.pointerId !== s.pointerId) return 161 - 162 - const dx = e.clientX - s.startX 163 - // if not dragging yet, check small threshold 164 - if (!s.dragging && Math.abs(dx) > SWIPE_CLICK_CANCEL_DISTANCE) { 165 - s.dragging = true 166 - } 167 - s.currentX = dx 168 - s.transition = 'none' 169 - dragStates.value[id] = s 170 - } 171 - 172 - function onPointerUp(id: number, e: PointerEvent) { 173 - const s = dragStates.value[id] 174 - const el = e.currentTarget as HTMLElement 175 - if (s?.pointerId !== undefined) { 176 - try { 177 - el.releasePointerCapture(s.pointerId) 178 - } catch { 179 - // ignore 180 - } 181 - } 182 - 183 - if (!s) { 184 - // nothing to do 185 - startAutoDismiss(id) 186 - return 187 - } 188 - 189 - const dx = s.currentX || 0 190 - const absDx = Math.abs(dx) 191 - const width = s.width ?? el.offsetWidth ?? 0 192 - const threshold = Math.max(SWIPE_REMOVAL_MIN, width * SWIPE_REMOVAL_THRESHOLD_RATIO) 193 - 194 - // if user dragged past threshold -> animate out in that direction 195 - if (absDx >= threshold) { 196 - const dir = dx > 0 ? 1 : -1 197 - beginRemovingAnimated(id, dir) 198 - return 199 - } 200 - 201 - // otherwise revert to original position 202 - s.currentX = 0 203 - s.dragging = false 204 - s.transition = `transform ${SWIPE_OUT_ANIM_MS}ms ease, opacity ${SWIPE_OUT_ANIM_MS}ms ease` 205 - dragStates.value[id] = s 206 - 207 - // clear the drag state after the transition to keep the DOM tidy 208 - window.setTimeout(() => { 209 - // only delete if not removed in the meantime 210 - const now = dragStates.value[id] 211 - if (now && !now.removing && !now.dragging) { 212 - delete dragStates.value[id] 213 - } 214 - }, SWIPE_OUT_ANIM_MS + 20) 215 - 216 - // restart auto-dismiss 217 - startAutoDismiss(id) 218 - } 219 - 220 - function onPointerCancel(id: number, e: PointerEvent) { 221 - // treat like pointerup (reset) 222 - onPointerUp(id, e) 223 - } 224 - 225 - /* Clicking should dismiss if the user wasn't dragging */ 226 - function onToastClick(id: number, _e: MouseEvent) { 227 - const s = dragStates.value[id] 228 - // if dragging, ignore click 229 - if (s?.dragging) return 230 - beginRemovingAnimated(id) 231 - } 232 - 233 - /* Pause auto-dismiss on hover; resume on leave */ 234 - function onMouseEnter(id: number) { 235 - clearAutoDismiss(id) 236 - } 237 - function onMouseLeave(id: number) { 238 - // only restart if not being dragged and toast still exists 239 - if (!dragStates.value[id]?.dragging) { 240 - startAutoDismiss(id) 241 - } 242 - } 243 - 244 - /* Inline style computed per toast id */ 245 - function toastStyle(id: number) { 246 - const s = dragStates.value[id] 247 - const base: Record<string, string> = {} 248 - // Default: no transform 249 - let transform = 'translateX(0)' 250 - let opacity = '1' 251 - let transition = 252 - s?.transition ?? `transform ${SWIPE_OUT_ANIM_MS}ms ease, opacity ${SWIPE_OUT_ANIM_MS}ms ease` 253 - 254 - if (s) { 255 - // If removing and we used horizontal slide (currentX large magnitude), use that 256 - if (s.removing && Math.abs(s.currentX) > 0) { 257 - transform = `translateX(${s.currentX}px)` 258 - opacity = '0' 259 - } else if (s.removing && s.currentX === 0) { 260 - // translateY-out fallback 261 - transform = `translateY(-0.5rem)` 262 - opacity = '0' 263 - } else if (s.dragging) { 264 - transform = `translateX(${s.currentX}px)` 265 - // reduce opacity slightly as it's dragged farther 266 - const w = s.width ?? 200 267 - const a = Math.min(1, Math.abs(s.currentX) / (w * 0.8)) 268 - opacity = String(1 - Math.min(0.6, a * 0.6)) 269 - transition = 'none' 270 - } else { 271 - transform = `translateX(${s.currentX}px)` 272 - } 273 - } 274 - 275 - base.transform = transform 276 - base.opacity = opacity 277 - base.transition = transition 278 - return base 279 - } 280 - 281 - /* Start auto-dismiss timers for any pre-existing toasts */ 282 - nextTick(() => { 283 - toasts.value.forEach((t) => startAutoDismiss(t.id)) 284 - }) 285 - 286 - /* Clean up timers on unmount */ 287 - onBeforeUnmount(() => { 288 - autoTimers.forEach((v) => window.clearTimeout(v)) 289 - autoTimers.clear() 290 - }) 291 39 </script> 292 40 293 41 <template> 294 42 <Teleport to="body"> 295 - <!-- Use TransitionGroup so entering/leaving animations run for "natural" adds/removes. 296 - Note: swiped-away toasts use manual animated removal and are removed after the animation. --> 297 - <TransitionGroup name="toast" tag="div" class="toast-stack"> 298 - <div 299 - v-for="toast in toasts" 300 - :key="toast.id" 301 - :class="['toast', toast.type]" 302 - :style="toastStyle(toast.id)" 303 - aria-live="polite" 304 - role="status" 305 - @click="() => onToastClick(toast.id, $event)" 306 - @pointerdown="(e) => onPointerDown(toast.id, e)" 307 - @pointermove="(e) => onPointerMove(toast.id, e)" 308 - @pointerup="(e) => onPointerUp(toast.id, e)" 309 - @pointercancel="(e) => onPointerCancel(toast.id, e)" 310 - @mouseenter="() => onMouseEnter(toast.id)" 311 - @mouseleave="() => onMouseLeave(toast.id)" 312 - > 43 + <div class="toast-stack"> 44 + <div v-for="toast in toasts" :key="toast.id" :class="['toast', toast.type]"> 313 45 <div class="icon"> 314 46 <component :is="toastIcons[toast.type]" /> 315 47 </div> 316 48 <span class="message">{{ toast.message }}</span> 317 49 </div> 318 - </TransitionGroup> 50 + </div> 319 51 </Teleport> 320 52 </template> 321 53 ··· 334 66 335 67 width: 100%; 336 68 padding: 0 1rem; 337 - pointer-events: none; /* container doesn't block; individual toasts receive events */ 338 69 } 339 70 340 - /* Durations used by TransitionGroup (enter/leave for new toasts) */ 341 - $enter-duration: 240ms; 342 - $leave-duration: 180ms; 343 - $move-duration: 200ms; 344 - 345 71 .toast { 346 72 display: inline-flex; 347 73 align-items: center; ··· 354 80 padding-right: 1rem; 355 81 gap: 0.5rem; 356 82 border-radius: 2rem; 357 - 358 - pointer-events: auto; /* allow interactions on the toast itself */ 359 83 360 84 &.success { 361 85 border: 2px solid hsla(var(--green) / 0.5); ··· 373 97 align-items: center; 374 98 justify-content: center; 375 99 } 376 - } 377 - 378 - /* Transition classes generated by TransitionGroup with name="toast" */ 379 - /* Entering: start slightly lower and faded, then slide to natural position */ 380 - .toast-enter-from { 381 - opacity: 0; 382 - transform: translateY(0.5rem) scale(0.99); 383 - } 384 - .toast-enter-active { 385 - transition: 386 - opacity $enter-duration ease, 387 - transform $enter-duration cubic-bezier(0.2, 0.9, 0.2, 1); 388 - } 389 - .toast-enter-to { 390 - opacity: 1; 391 - transform: translateY(0) scale(1); 392 - } 393 - 394 - /* Leaving: fade and slide down a bit */ 395 - .toast-leave-from { 396 - opacity: 1; 397 - transform: translateY(0) scale(1); 398 - } 399 - .toast-leave-active { 400 - transition: 401 - opacity $leave-duration ease, 402 - transform $leave-duration cubic-bezier(0.2, 0.9, 0.2, 1); 403 - } 404 - .toast-leave-to { 405 - opacity: 0; 406 - transform: translateY(0.5rem) scale(0.99); 407 - } 408 - 409 - /* When items reorder / move */ 410 - .toast-move { 411 - transition: transform $move-duration cubic-bezier(0.2, 0.9, 0.2, 1); 412 - will-change: transform; 413 100 } 414 101 </style>