feat: receipt lightbox component with zoom and focus trap
This commit is contained in:
112
src/components/ReceiptLightbox.vue
Normal file
112
src/components/ReceiptLightbox.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user