strat-gameplay-webapp/frontend-sba/components/UI/BottomSheet.vue
Cal Corum be31e2ccb4 CLAUDE: Complete in-game UI overhaul with player cards and outcome wizard
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>
2026-01-23 15:23:38 -06:00

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>