Files
nav-carte/components/MobileSheet.vue
2026-04-28 14:00:05 +02:00

219 lines
5.4 KiB
Vue

<!--
MobileSheet Bottom sheet swipable 3 états (collapsed / half / full)
Pattern Google Maps mobile
États :
- collapsed : juste la poignée + compteur (~56px)
- half : ~50dvh (état initial)
- full : ~92dvh (plein écran)
Déclenché par touch/drag sur la poignée
-->
<template>
<div
class="mobile-sheet"
:class="`mobile-sheet--${state}`"
:style="{ transform: `translateY(${dragOffset}px)` }"
>
<!-- Poignée drag -->
<div
class="sheet-handle-area"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
@touchend="onTouchEnd"
>
<div class="sheet-handle-bar" />
</div>
<!-- Compteur compact (toujours visible) -->
<div class="sheet-header" @click="onHeaderClick">
<span class="sheet-counter">
<span v-if="pending">Chargement</span>
<span v-else>{{ resultCount }} fiche{{ resultCount > 1 ? 's' : '' }}</span>
</span>
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" aria-hidden="true"
class="sheet-chevron"
:class="{ 'sheet-chevron--up': state !== 'collapsed' }"
style="color: var(--nav-text-muted); flex-shrink: 0;"
>
<polyline points="18 15 12 9 6 15"/>
</svg>
</div>
<!-- Contenu scrollable (caché si collapsed) -->
<div
v-show="state !== 'collapsed'"
ref="contentEl"
class="sheet-content"
>
<slot />
</div>
</div>
</template>
<script setup lang="ts">
type SheetState = 'collapsed' | 'half' | 'full'
const props = defineProps<{
resultCount: number
pending?: boolean
}>()
const emit = defineEmits<{
'state-change': [state: SheetState]
}>()
const state = ref<SheetState>('half')
const dragOffset = ref(0)
const contentEl = ref<HTMLElement | null>(null)
let touchStartY = 0
let touchCurrentY = 0
let isDragging = false
// Cycle états au clic header
function onHeaderClick() {
if (state.value === 'collapsed') {
setSheetState('half')
} else if (state.value === 'half') {
setSheetState('full')
} else {
setSheetState('collapsed')
}
}
function setSheetState(next: SheetState) {
state.value = next
dragOffset.value = 0
emit('state-change', next)
}
// Touch handlers
function onTouchStart(e: TouchEvent) {
touchStartY = e.touches[0].clientY
touchCurrentY = touchStartY
isDragging = true
dragOffset.value = 0
}
function onTouchMove(e: TouchEvent) {
if (!isDragging) return
touchCurrentY = e.touches[0].clientY
const delta = touchCurrentY - touchStartY
// Limiter le drag visuellement (résistance)
dragOffset.value = delta * 0.6
}
function onTouchEnd() {
if (!isDragging) return
isDragging = false
const delta = touchCurrentY - touchStartY
const threshold = 60 // px minimum pour changer d'état
if (delta > threshold) {
// Swipe vers le bas → état inférieur
if (state.value === 'full') setSheetState('half')
else if (state.value === 'half') setSheetState('collapsed')
else setSheetState('collapsed')
} else if (delta < -threshold) {
// Swipe vers le haut → état supérieur
if (state.value === 'collapsed') setSheetState('half')
else if (state.value === 'half') setSheetState('full')
else setSheetState('full')
} else {
// Snap back
dragOffset.value = 0
}
// Transition smooth au relâchement
dragOffset.value = 0
}
</script>
<style scoped>
.mobile-sheet {
position: fixed;
inset-x: 0;
bottom: 0;
z-index: 500;
background: var(--nav-surface);
border-radius: 16px 16px 0 0;
box-shadow: 0 -4px 24px rgba(26, 34, 56, 0.14);
display: flex;
flex-direction: column;
transition: height 0.3s cubic-bezier(0.32, 0.72, 0, 1), transform 0.05s linear;
will-change: height, transform;
overflow: hidden;
}
/* ── États hauteur ───────────────────────────────────────────────────── */
.mobile-sheet--collapsed {
height: 56px;
}
.mobile-sheet--half {
height: 50dvh;
min-height: 200px;
}
.mobile-sheet--full {
height: 92dvh;
}
/* ── Poignée ─────────────────────────────────────────────────────────── */
.sheet-handle-area {
display: flex;
justify-content: center;
padding: 10px 0 4px;
cursor: grab;
flex-shrink: 0;
}
.sheet-handle-area:active {
cursor: grabbing;
}
.sheet-handle-bar {
width: 36px;
height: 4px;
border-radius: 2px;
background: var(--nav-bg-alt);
}
/* ── Header compteur ─────────────────────────────────────────────────── */
.sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px 8px;
cursor: pointer;
flex-shrink: 0;
gap: 8px;
}
.sheet-counter {
font-size: 0.8rem;
font-weight: 600;
color: var(--nav-text-muted);
}
.sheet-chevron {
transition: transform 0.2s ease;
}
.sheet-chevron--up {
transform: rotate(180deg);
}
/* ── Contenu scrollable ──────────────────────────────────────────────── */
.sheet-content {
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
</style>