151 lines
3.1 KiB
Vue
151 lines
3.1 KiB
Vue
<template>
|
|
<Teleport to="body">
|
|
<Transition name="sheet">
|
|
<div v-if="open" class="sheet-backdrop" @click.self="$emit('close')" @keydown.escape="$emit('close')">
|
|
<div
|
|
class="sheet"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
:aria-label="label"
|
|
ref="sheetEl"
|
|
tabindex="-1"
|
|
:style="dragStyle"
|
|
@touchstart="onTouchStart"
|
|
@touchmove="onTouchMove"
|
|
@touchend="onTouchEnd"
|
|
>
|
|
<div class="sheet__handle" aria-hidden="true" />
|
|
<slot />
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, nextTick } from 'vue'
|
|
|
|
defineProps<{
|
|
open: boolean
|
|
label: string
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
close: []
|
|
}>()
|
|
|
|
const sheetEl = ref<HTMLElement | null>(null)
|
|
|
|
watch(
|
|
() => sheetEl.value,
|
|
async (el) => {
|
|
if (el) {
|
|
await nextTick()
|
|
const firstInput = el.querySelector<HTMLElement>('input, textarea, button[type="submit"]')
|
|
if (firstInput) {
|
|
firstInput.focus()
|
|
} else {
|
|
el.focus()
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
/* ── Drag-to-dismiss ── */
|
|
const DISMISS_THRESHOLD = 100
|
|
const dragY = ref(0)
|
|
const dragging = ref(false)
|
|
let startY = 0
|
|
|
|
const dragStyle = computed(() => {
|
|
if (!dragging.value || dragY.value <= 0) return undefined
|
|
return {
|
|
transform: `translateY(${dragY.value}px)`,
|
|
transition: 'none',
|
|
}
|
|
})
|
|
|
|
function onTouchStart(e: TouchEvent) {
|
|
const touch = e.touches[0]
|
|
if (!touch) return
|
|
startY = touch.clientY
|
|
dragging.value = true
|
|
dragY.value = 0
|
|
}
|
|
|
|
function onTouchMove(e: TouchEvent) {
|
|
if (!dragging.value) return
|
|
const touch = e.touches[0]
|
|
if (!touch) return
|
|
const delta = touch.clientY - startY
|
|
if (delta > 0) e.preventDefault()
|
|
dragY.value = Math.max(0, delta)
|
|
}
|
|
|
|
function onTouchEnd() {
|
|
if (dragY.value >= DISMISS_THRESHOLD) {
|
|
emit('close')
|
|
}
|
|
dragging.value = false
|
|
dragY.value = 0
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.sheet-backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: var(--color-glass-overlay);
|
|
display: flex;
|
|
align-items: flex-end;
|
|
justify-content: center;
|
|
z-index: 100;
|
|
}
|
|
|
|
.sheet {
|
|
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
|
|
border: 1px solid var(--color-glass-border);
|
|
border-bottom: none;
|
|
backdrop-filter: blur(24px);
|
|
-webkit-backdrop-filter: blur(24px);
|
|
border-radius: 20px 20px 0 0;
|
|
padding: var(--spacing-lg) var(--spacing-xl) var(--spacing-2xl);
|
|
width: 100%;
|
|
max-width: var(--content-max-width);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--spacing-lg);
|
|
outline: none;
|
|
}
|
|
|
|
.sheet__handle {
|
|
width: 36px;
|
|
height: 4px;
|
|
background: var(--color-glass-border-hover);
|
|
border-radius: 2px;
|
|
align-self: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* Transition */
|
|
.sheet-enter-active,
|
|
.sheet-leave-active {
|
|
transition: opacity 0.25s ease;
|
|
}
|
|
|
|
.sheet-enter-active .sheet,
|
|
.sheet-leave-active .sheet {
|
|
transition: transform 0.25s ease;
|
|
}
|
|
|
|
.sheet-enter-from,
|
|
.sheet-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
.sheet-enter-from .sheet,
|
|
.sheet-leave-to .sheet {
|
|
transform: translateY(100%);
|
|
}
|
|
</style>
|