audio streaming app plyr.fm
38
fork

Configure Feed

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

fix(likers-sheet): make the drag handle actually swipe-to-dismiss (#1342)

the handle on the likers sheet was decorative — it signaled the iOS /
android bottom-sheet "drag-me-down" affordance but had no event
handlers. dismiss only worked via the small × button or backdrop tap,
which is poor for one-thumb use (e.g. queueing tracks on the move).

wire pointer events on an expanded hit-target around the handle, drive
the sheet's transform during drag, and dismiss past either an 80px
delta or 0.5 px/ms downward velocity. tap-backdrop and × are kept as
alternative dismiss paths.

extracted as a reusable svelte 5 attachment in `lib/swipe-to-dismiss.ts`
so the same fix can later land on AudioRevisionsSheet, Queue, and the
playlist/album/liked route sheets that share the pattern.

Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>

authored by

nate nowack
Claude Opus 4 (1M context)
and committed by
GitHub
5dc1f048 c213426b

+142 -4
+31 -4
frontend/src/lib/components/LikersSheet.svelte
··· 1 1 <script lang="ts"> 2 2 import { likersSheet } from '$lib/likers-sheet.svelte'; 3 3 import { getRefreshedAvatar, triggerAvatarRefresh, hasAttemptedRefresh } from '$lib/avatar-refresh.svelte'; 4 + import { swipeToDismiss } from '$lib/swipe-to-dismiss'; 4 5 import SensitiveImage from './SensitiveImage.svelte'; 5 6 import type { LikerData } from '$lib/tooltip-cache.svelte'; 6 7 ··· 40 41 role="presentation" 41 42 onclick={handleBackdropClick} 42 43 > 43 - <div class="sheet" role="dialog" aria-modal="true" aria-label="liked by"> 44 - <div class="sheet-handle"></div> 44 + <div 45 + class="sheet" 46 + role="dialog" 47 + aria-modal="true" 48 + aria-label="liked by" 49 + {@attach swipeToDismiss(() => likersSheet.close())} 50 + > 51 + <div class="sheet-handle-area" data-sheet-handle role="presentation"> 52 + <div class="sheet-handle"></div> 53 + </div> 45 54 <div class="sheet-header"> 46 55 <span class="sheet-title"> 47 56 {likersSheet.likeCount} {likersSheet.likeCount === 1 ? 'like' : 'likes'} ··· 138 147 transform: translateY(0); 139 148 } 140 149 150 + .sheet-handle-area { 151 + /* expanded hit target (>= 44px tall) so a thumb-flick anywhere along the 152 + top strip triggers swipe-to-dismiss. visible handle stays small. */ 153 + display: flex; 154 + justify-content: center; 155 + align-items: center; 156 + padding: 0.75rem 1rem 0.5rem; 157 + flex-shrink: 0; 158 + cursor: grab; 159 + /* prevent ios from interpreting the vertical drag as scroll */ 160 + touch-action: none; 161 + -webkit-user-select: none; 162 + user-select: none; 163 + } 164 + 165 + .sheet-handle-area:active { 166 + cursor: grabbing; 167 + } 168 + 141 169 .sheet-handle { 142 - width: 32px; 170 + width: 36px; 143 171 height: 4px; 144 172 background: var(--border-default); 145 173 border-radius: 2px; 146 - margin: 0.75rem auto 0; 147 174 flex-shrink: 0; 148 175 } 149 176
+111
frontend/src/lib/swipe-to-dismiss.ts
··· 1 + // swipe-to-dismiss attachment for bottom sheets. 2 + // 3 + // the visual handle on a bottom sheet is a near-universal mobile affordance 4 + // (iOS sheets, android bottom sheets) that signals "drag me down to close". 5 + // without this attachment the affordance lies — the handle is decorative and 6 + // the only dismiss is tapping a small × or the backdrop. 7 + // 8 + // usage: 9 + // <div class="sheet" {@attach swipeToDismiss(() => sheet.close())}> 10 + // <div class="sheet-handle-area" data-sheet-handle> 11 + // <div class="sheet-handle"></div> 12 + // </div> 13 + // ... 14 + // </div> 15 + // 16 + // the attachment is applied to the sheet element. it locates its handle by 17 + // `[data-sheet-handle]` and binds pointer listeners there. drag updates the 18 + // sheet's inline transform; release either dismisses (call `onDismiss`) past 19 + // the threshold/velocity, or snaps back by clearing the inline transform. 20 + 21 + import type { Attachment } from 'svelte/attachments'; 22 + 23 + interface SwipeToDismissOptions { 24 + /** css selector for the drag handle within the sheet (default: `[data-sheet-handle]`) */ 25 + handleSelector?: string; 26 + /** distance in px past which release dismisses (default: 80) */ 27 + dismissDeltaPx?: number; 28 + /** downward velocity in px/ms past which release dismisses (default: 0.5) */ 29 + dismissVelocityPxPerMs?: number; 30 + /** ms to wait before clearing the inline dismiss transform (default: 250, matches sheet transition) */ 31 + dismissResetMs?: number; 32 + } 33 + 34 + export function swipeToDismiss( 35 + onDismiss: () => void, 36 + options: SwipeToDismissOptions = {} 37 + ): Attachment<HTMLElement> { 38 + const { 39 + handleSelector = '[data-sheet-handle]', 40 + dismissDeltaPx = 80, 41 + dismissVelocityPxPerMs = 0.5, 42 + dismissResetMs = 250 43 + } = options; 44 + 45 + return (sheet) => { 46 + const handle = sheet.querySelector<HTMLElement>(handleSelector); 47 + if (!handle) return; 48 + 49 + let dragging = false; 50 + let startY = 0; 51 + let delta = 0; 52 + let lastY = 0; 53 + let lastT = 0; 54 + let velocity = 0; 55 + 56 + function down(event: PointerEvent) { 57 + dragging = true; 58 + startY = event.clientY; 59 + lastY = event.clientY; 60 + lastT = event.timeStamp; 61 + delta = 0; 62 + velocity = 0; 63 + handle!.setPointerCapture(event.pointerId); 64 + sheet.style.transition = 'none'; 65 + } 66 + 67 + function move(event: PointerEvent) { 68 + if (!dragging) return; 69 + const d = Math.max(0, event.clientY - startY); 70 + const dt = event.timeStamp - lastT; 71 + if (dt > 0) velocity = (event.clientY - lastY) / dt; 72 + lastY = event.clientY; 73 + lastT = event.timeStamp; 74 + delta = d; 75 + sheet.style.transform = `translateY(${d}px)`; 76 + } 77 + 78 + function up() { 79 + if (!dragging) return; 80 + dragging = false; 81 + // restore the css transition so snap-back / dismiss animate 82 + sheet.style.transition = ''; 83 + const shouldDismiss = delta > dismissDeltaPx || velocity > dismissVelocityPxPerMs; 84 + if (shouldDismiss) { 85 + // drive the dismiss animation explicitly so it flows from the 86 + // dragged position out of view, regardless of when the parent's 87 + // open class flips. 88 + sheet.style.transform = 'translateY(100%)'; 89 + onDismiss(); 90 + setTimeout(() => { 91 + sheet.style.transform = ''; 92 + }, dismissResetMs); 93 + } else { 94 + // snap back: clear inline transform so the open css rule wins 95 + sheet.style.transform = ''; 96 + } 97 + } 98 + 99 + handle.addEventListener('pointerdown', down); 100 + handle.addEventListener('pointermove', move); 101 + handle.addEventListener('pointerup', up); 102 + handle.addEventListener('pointercancel', up); 103 + 104 + return () => { 105 + handle.removeEventListener('pointerdown', down); 106 + handle.removeEventListener('pointermove', move); 107 + handle.removeEventListener('pointerup', up); 108 + handle.removeEventListener('pointercancel', up); 109 + }; 110 + }; 111 + }