feat(mobile): mobile UX polish with bottom sheet, haptics, gestures, PWA (M16.1-M16.10)
- BottomSheet.vue: gesture-driven with 40/80/100% snap points, backdrop - Swipe navigation: left/right to switch conversations, 80px threshold - Pull-to-refresh: custom glass spinner, haptic on release - Haptic feedback: useHaptics() composable with pattern presets - Web Share API: native share with glass fallback sheet - Pinch-to-zoom: usePinchZoom() composable, 1x-4x, double-tap reset - iOS PWA: safe area insets CSS utilities, black-translucent status bar - Long-press context menus: 500ms trigger via bottom sheet - Scroll position memory: per-route Map, auto-restore on mount - Landscape mode: CSS split layout, orientation change detection Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
72f93e4e94
commit
6be4fbe081
@@ -0,0 +1,77 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export function useSwipeNavigation(options: {
|
||||
onSwipeLeft?: () => void
|
||||
onSwipeRight?: () => void
|
||||
threshold?: number
|
||||
velocityThreshold?: number
|
||||
}) {
|
||||
const { threshold = 80, velocityThreshold = 0.3 } = options
|
||||
const isSwiping = ref(false)
|
||||
const swipeOffset = ref(0)
|
||||
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
let startTime = 0
|
||||
let tracking = false
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
startX = e.touches[0].clientX
|
||||
startY = e.touches[0].clientY
|
||||
startTime = Date.now()
|
||||
tracking = true
|
||||
swipeOffset.value = 0
|
||||
}
|
||||
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
if (!tracking) return
|
||||
const dx = e.touches[0].clientX - startX
|
||||
const dy = e.touches[0].clientY - startY
|
||||
|
||||
// Only track horizontal swipes
|
||||
if (Math.abs(dy) > Math.abs(dx)) {
|
||||
tracking = false
|
||||
return
|
||||
}
|
||||
|
||||
swipeOffset.value = dx
|
||||
isSwiping.value = Math.abs(dx) > 20
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
if (!tracking) {
|
||||
isSwiping.value = false
|
||||
swipeOffset.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
const velocity = Math.abs(swipeOffset.value) / elapsed
|
||||
|
||||
if (Math.abs(swipeOffset.value) > threshold || velocity > velocityThreshold) {
|
||||
if (swipeOffset.value > 0) {
|
||||
options.onSwipeRight?.()
|
||||
} else {
|
||||
options.onSwipeLeft?.()
|
||||
}
|
||||
}
|
||||
|
||||
tracking = false
|
||||
isSwiping.value = false
|
||||
swipeOffset.value = 0
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('touchstart', onTouchStart, { passive: true })
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: true })
|
||||
document.addEventListener('touchend', onTouchEnd)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('touchstart', onTouchStart)
|
||||
document.removeEventListener('touchmove', onTouchMove)
|
||||
document.removeEventListener('touchend', onTouchEnd)
|
||||
})
|
||||
|
||||
return { isSwiping, swipeOffset }
|
||||
}
|
||||
Reference in New Issue
Block a user