Features: - PlayerCardModal: Tap any player to view full playing card image - OutcomeWizard: Progressive 3-step outcome selection (On Base/Out/X-Check) - GameBoard: Expandable view showing all 9 fielder positions - Post-roll card display: Shows batter/pitcher card based on d6 roll - CurrentSituation: Tappable player cards with modal integration Bug fixes: - Fix batter not advancing after play (state_manager recovery logic) - Add dark mode support for buttons and panels (partial - iOS issue noted) New files: - PlayerCardModal.vue, OutcomeWizard.vue, BottomSheet.vue - outcomeFlow.ts constants for outcome category mapping - TEST_PLAN_UI_OVERHAUL.md with 23/24 tests passing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
312 lines
6.7 KiB
Vue
312 lines
6.7 KiB
Vue
<template>
|
|
<Teleport to="body">
|
|
<!-- Backdrop (only visible when expanded) -->
|
|
<Transition name="fade">
|
|
<div
|
|
v-if="isOpen && !isMinimized"
|
|
class="bottom-sheet-backdrop"
|
|
@click="minimize"
|
|
/>
|
|
</Transition>
|
|
|
|
<!-- Bottom Sheet Container -->
|
|
<Transition name="slide">
|
|
<div
|
|
v-if="isOpen"
|
|
ref="sheetRef"
|
|
class="bottom-sheet-container"
|
|
:class="{ 'is-minimized': isMinimized }"
|
|
:style="sheetStyle"
|
|
@touchstart="onTouchStart"
|
|
@touchmove="onTouchMove"
|
|
@touchend="onTouchEnd"
|
|
>
|
|
<!-- Drag Handle -->
|
|
<div class="drag-handle" @click="toggleMinimize">
|
|
<div class="drag-indicator" />
|
|
<span v-if="isMinimized" class="minimize-label">{{ title }} - Tap to expand</span>
|
|
</div>
|
|
|
|
<!-- Sheet Content -->
|
|
<div v-show="!isMinimized" class="sheet-content">
|
|
<slot />
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- Floating Restore Button (shown when minimized) -->
|
|
<Transition name="pop">
|
|
<button
|
|
v-if="isOpen && isMinimized && showFloatingButton"
|
|
class="floating-restore-button"
|
|
@click="expand"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
|
</svg>
|
|
<span class="button-label">{{ title }}</span>
|
|
</button>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
|
|
|
interface Props {
|
|
isOpen: boolean
|
|
title?: string
|
|
showFloatingButton?: boolean
|
|
minimizeThreshold?: number
|
|
startMinimized?: boolean
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
title: 'Panel',
|
|
showFloatingButton: true,
|
|
minimizeThreshold: 100,
|
|
startMinimized: false,
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
close: []
|
|
minimize: []
|
|
expand: []
|
|
}>()
|
|
|
|
// State
|
|
const sheetRef = ref<HTMLElement | null>(null)
|
|
const isMinimized = ref(props.startMinimized)
|
|
const touchStartY = ref(0)
|
|
const touchCurrentY = ref(0)
|
|
const isDragging = ref(false)
|
|
const dragOffset = ref(0)
|
|
|
|
// Computed style for drag animation
|
|
const sheetStyle = computed(() => {
|
|
if (isDragging.value && dragOffset.value > 0) {
|
|
return {
|
|
transform: `translateY(${dragOffset.value}px)`,
|
|
transition: 'none',
|
|
}
|
|
}
|
|
return {}
|
|
})
|
|
|
|
// Touch handlers for swipe gestures
|
|
function onTouchStart(event: TouchEvent) {
|
|
touchStartY.value = event.touches[0].clientY
|
|
isDragging.value = true
|
|
}
|
|
|
|
function onTouchMove(event: TouchEvent) {
|
|
if (!isDragging.value) return
|
|
|
|
touchCurrentY.value = event.touches[0].clientY
|
|
const deltaY = touchCurrentY.value - touchStartY.value
|
|
|
|
// Only allow dragging down (positive delta)
|
|
if (deltaY > 0 && !isMinimized.value) {
|
|
dragOffset.value = deltaY
|
|
}
|
|
|
|
// Allow dragging up when minimized
|
|
if (deltaY < 0 && isMinimized.value) {
|
|
// Subtle feedback for upward drag
|
|
dragOffset.value = Math.max(deltaY / 2, -30)
|
|
}
|
|
}
|
|
|
|
function onTouchEnd() {
|
|
if (!isDragging.value) return
|
|
isDragging.value = false
|
|
|
|
const deltaY = touchCurrentY.value - touchStartY.value
|
|
|
|
// Swipe down to minimize
|
|
if (deltaY > props.minimizeThreshold && !isMinimized.value) {
|
|
minimize()
|
|
}
|
|
|
|
// Swipe up to expand
|
|
if (deltaY < -50 && isMinimized.value) {
|
|
expand()
|
|
}
|
|
|
|
// Reset drag offset
|
|
dragOffset.value = 0
|
|
}
|
|
|
|
// Actions
|
|
function minimize() {
|
|
isMinimized.value = true
|
|
emit('minimize')
|
|
}
|
|
|
|
function expand() {
|
|
isMinimized.value = false
|
|
emit('expand')
|
|
}
|
|
|
|
function toggleMinimize() {
|
|
if (isMinimized.value) {
|
|
expand()
|
|
} else {
|
|
minimize()
|
|
}
|
|
}
|
|
|
|
// Reset state when closed
|
|
watch(() => props.isOpen, (isOpen) => {
|
|
if (!isOpen) {
|
|
isDragging.value = false
|
|
dragOffset.value = 0
|
|
touchStartY.value = 0
|
|
touchCurrentY.value = 0
|
|
} else if (props.startMinimized) {
|
|
isMinimized.value = true
|
|
}
|
|
})
|
|
|
|
// Prevent body scroll when sheet is open and expanded
|
|
watch([() => props.isOpen, isMinimized], ([open, minimized]) => {
|
|
if (open && !minimized) {
|
|
document.body.style.overflow = 'hidden'
|
|
} else {
|
|
document.body.style.overflow = ''
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
document.body.style.overflow = ''
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Backdrop */
|
|
.bottom-sheet-backdrop {
|
|
@apply fixed inset-0 z-40 bg-black/40 backdrop-blur-sm;
|
|
}
|
|
|
|
/* Container */
|
|
.bottom-sheet-container {
|
|
@apply fixed bottom-0 left-0 right-0 z-50;
|
|
@apply bg-white rounded-t-2xl shadow-2xl;
|
|
@apply max-h-[85vh] overflow-hidden;
|
|
@apply transition-all duration-300 ease-out;
|
|
}
|
|
|
|
.bottom-sheet-container.is-minimized {
|
|
@apply max-h-14;
|
|
@apply shadow-lg;
|
|
}
|
|
|
|
/* Drag Handle */
|
|
.drag-handle {
|
|
@apply flex flex-col items-center py-3 cursor-grab active:cursor-grabbing;
|
|
@apply border-b border-gray-100;
|
|
}
|
|
|
|
.drag-indicator {
|
|
@apply w-10 h-1 bg-gray-300 rounded-full;
|
|
}
|
|
|
|
.minimize-label {
|
|
@apply mt-1 text-xs font-medium text-gray-500;
|
|
}
|
|
|
|
/* Sheet Content */
|
|
.sheet-content {
|
|
@apply max-h-[calc(85vh-56px)] overflow-y-auto;
|
|
@apply p-4;
|
|
}
|
|
|
|
/* Floating Restore Button */
|
|
.floating-restore-button {
|
|
@apply fixed bottom-20 left-1/2 -translate-x-1/2 z-50;
|
|
@apply flex items-center gap-2 px-4 py-2;
|
|
@apply bg-blue-600 hover:bg-blue-700 text-white;
|
|
@apply rounded-full shadow-lg;
|
|
@apply font-medium text-sm;
|
|
@apply transition-all duration-200;
|
|
@apply focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2;
|
|
}
|
|
|
|
.button-label {
|
|
@apply whitespace-nowrap;
|
|
}
|
|
|
|
/* Transitions */
|
|
.fade-enter-active,
|
|
.fade-leave-active {
|
|
@apply transition-opacity duration-300;
|
|
}
|
|
|
|
.fade-enter-from,
|
|
.fade-leave-to {
|
|
@apply opacity-0;
|
|
}
|
|
|
|
.slide-enter-active {
|
|
@apply transition-transform duration-300 ease-out;
|
|
}
|
|
|
|
.slide-leave-active {
|
|
@apply transition-transform duration-200 ease-in;
|
|
}
|
|
|
|
.slide-enter-from,
|
|
.slide-leave-to {
|
|
@apply translate-y-full;
|
|
}
|
|
|
|
.pop-enter-active {
|
|
@apply transition-all duration-300 ease-out;
|
|
}
|
|
|
|
.pop-leave-active {
|
|
@apply transition-all duration-200 ease-in;
|
|
}
|
|
|
|
.pop-enter-from,
|
|
.pop-leave-to {
|
|
@apply opacity-0 scale-75 translate-y-4;
|
|
}
|
|
|
|
/* Dark mode */
|
|
@media (prefers-color-scheme: dark) {
|
|
.bottom-sheet-container {
|
|
@apply bg-gray-800;
|
|
}
|
|
|
|
.drag-handle {
|
|
@apply border-gray-700;
|
|
}
|
|
|
|
.drag-indicator {
|
|
@apply bg-gray-600;
|
|
}
|
|
|
|
.minimize-label {
|
|
@apply text-gray-400;
|
|
}
|
|
}
|
|
|
|
/* Desktop adjustments */
|
|
@media (min-width: 768px) {
|
|
.bottom-sheet-container {
|
|
@apply max-w-lg left-1/2 -translate-x-1/2 right-auto;
|
|
@apply max-h-[70vh];
|
|
}
|
|
|
|
.bottom-sheet-container.is-minimized {
|
|
@apply translate-x-0 left-auto right-4 rounded-t-xl;
|
|
@apply max-w-xs;
|
|
}
|
|
|
|
.floating-restore-button {
|
|
@apply bottom-6 right-6 left-auto translate-x-0;
|
|
}
|
|
}
|
|
</style>
|