feat: receipt lightbox component with zoom and focus trap

This commit is contained in:
Your Name
2026-02-20 15:23:11 +02:00
parent f337f8c28f
commit 7c8effb0a7

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, onUnmounted } from 'vue'
import { useFocusTrap } from '../utils/focusTrap'
import { X, ZoomIn, ZoomOut } from 'lucide-vue-next'
const props = defineProps<{
show: boolean
imageUrl: string
description: string
category: string
date: string
amount: string
}>()
const emit = defineEmits<{
close: []
}>()
const { activate: activateTrap, deactivate: deactivateTrap } = useFocusTrap()
const dialogRef = ref<HTMLElement | null>(null)
const zoom = ref(1)
const altText = computed(() =>
`Receipt for ${props.category} expense on ${props.date}, ${props.amount}`
)
watch(() => props.show, async (val) => {
if (val) {
zoom.value = 1
await nextTick()
if (dialogRef.value) {
activateTrap(dialogRef.value, { onDeactivate: () => emit('close') })
}
} else {
deactivateTrap()
}
})
onUnmounted(() => deactivateTrap())
function zoomIn() {
zoom.value = Math.min(zoom.value + 0.25, 3)
}
function zoomOut() {
zoom.value = Math.max(zoom.value - 0.25, 0.5)
}
function onKeydown(e: KeyboardEvent) {
if (e.key === '+' || e.key === '=') zoomIn()
if (e.key === '-') zoomOut()
}
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center p-4 z-[60]"
@click.self="$emit('close')"
@keydown="onKeydown"
>
<div
ref="dialogRef"
role="dialog"
aria-modal="true"
:aria-label="'Receipt: ' + description"
class="relative max-w-[90vw] max-h-[90vh] flex flex-col items-center"
>
<!-- Controls -->
<div class="absolute top-0 right-0 flex items-center gap-2 p-2 z-10">
<button
@click="zoomOut"
class="p-2 bg-bg-surface/80 rounded-lg text-text-primary hover:bg-bg-surface transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Zoom out"
>
<ZoomOut class="w-4 h-4" aria-hidden="true" />
</button>
<span class="text-[0.75rem] text-white/80 min-w-[3rem] text-center" aria-live="polite">
{{ Math.round(zoom * 100) }}%
</span>
<button
@click="zoomIn"
class="p-2 bg-bg-surface/80 rounded-lg text-text-primary hover:bg-bg-surface transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Zoom in"
>
<ZoomIn class="w-4 h-4" aria-hidden="true" />
</button>
<button
@click="$emit('close')"
class="p-2 bg-bg-surface/80 rounded-lg text-text-primary hover:bg-bg-surface transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
aria-label="Close lightbox"
>
<X class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<!-- Image -->
<div class="overflow-auto max-w-[90vw] max-h-[85vh]">
<img
:src="imageUrl"
:alt="altText"
:style="{ transform: `scale(${zoom})`, transformOrigin: 'center center' }"
class="transition-transform duration-150"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>