feat: improve public image states
Quality / test (push) Canceled after 0s

Add explicit public image loading and unavailable states, accessible selected-thumbnail state, and Storybook coverage.
This commit is contained in:
2026-09-09 02:28:33 -04:00
parent ec078423e2
commit 88ef61353e
2 changed files with 33 additions and 4 deletions
@@ -23,4 +23,11 @@ export const BrokenImageFallback = {
args: {
images: [{ id: 'broken', src: '/fixtures/missing-image.webp', alt: 'Unavailable collectible image' }],
},
};
export const LoadingImage = {
args: {
images: [{ id: 'loading', src: '/fixtures/slow-image.webp', alt: 'Collectible image loading' }],
},
parameters: { chromatic: { delay: 1000 } },
};
@@ -1,25 +1,47 @@
<script setup>
import { ref } from 'vue';
import { onMounted, ref } from 'vue';
const props = defineProps({
images: { type: Array, required: true },
});
const selectedIndex = ref(0);
const failedImages = ref(new Set());
const loadingImages = ref(new Set());
function imageKey(image) {
return image?.id ?? '';
}
function markLoading(image) {
if (!image?.src && !image?.url) return;
loadingImages.value = new Set(loadingImages.value).add(imageKey(image));
}
function markFailed(imageId) {
failedImages.value = new Set(failedImages.value).add(imageId);
const nextLoadingImages = new Set(loadingImages.value);
nextLoadingImages.delete(imageId);
loadingImages.value = nextLoadingImages;
}
function markLoaded(imageId) {
const nextLoadingImages = new Set(loadingImages.value);
nextLoadingImages.delete(imageId);
loadingImages.value = nextLoadingImages;
}
onMounted(() => markLoading(props.images[selectedIndex.value]));
</script>
<template>
<div class="image-gallery">
<div class="image-gallery__stage">
<img v-if="(props.images[selectedIndex]?.src ?? props.images[selectedIndex]?.url) && !failedImages.has(props.images[selectedIndex].id)" :src="props.images[selectedIndex].src ?? props.images[selectedIndex].url" :alt="props.images[selectedIndex].altText ?? props.images[selectedIndex].alt" @error="markFailed(props.images[selectedIndex].id)" />
<span v-else class="image-gallery__placeholder" role="img" :aria-label="props.images[selectedIndex]?.src ? 'Collectible image unavailable' : 'No collectible image available'">{{ props.images[selectedIndex]?.src ? 'IMAGE UNAVAILABLE' : (props.images[selectedIndex]?.label ?? 'IMAGE') }}</span>
<img v-if="(props.images[selectedIndex]?.src ?? props.images[selectedIndex]?.url) && !failedImages.has(props.images[selectedIndex].id)" :src="props.images[selectedIndex].src ?? props.images[selectedIndex].url" :alt="props.images[selectedIndex].altText ?? props.images[selectedIndex].alt" @load="markLoaded(props.images[selectedIndex].id)" @error="markFailed(props.images[selectedIndex].id)" />
<span v-if="loadingImages.has(imageKey(props.images[selectedIndex]))" class="image-gallery__placeholder" role="status" aria-label="Loading collectible image">Loading image...</span>
<span v-else-if="!((props.images[selectedIndex]?.src ?? props.images[selectedIndex]?.url) && !failedImages.has(props.images[selectedIndex].id))" class="image-gallery__placeholder" role="img" :aria-label="props.images[selectedIndex]?.src ? 'Collectible image unavailable' : 'No collectible image available'">{{ props.images[selectedIndex]?.src ? 'IMAGE UNAVAILABLE' : (props.images[selectedIndex]?.label ?? 'IMAGE') }}</span>
</div>
<div v-if="props.images.length > 1" class="image-gallery__controls" aria-label="Gallery images">
<button v-for="(image, index) in props.images" :key="image.id" :class="{ 'image-gallery__thumb--selected': index === selectedIndex }" type="button" :aria-label="`Show image ${index + 1}: ${image.alt}`" @click="selectedIndex = index">
<button v-for="(image, index) in props.images" :key="image.id" :class="{ 'image-gallery__thumb--selected': index === selectedIndex }" type="button" :aria-label="`Show image ${index + 1}: ${image.alt}`" :aria-current="index === selectedIndex ? 'true' : undefined" @click="selectedIndex = index; markLoading(image)">
{{ index + 1 }}
</button>
</div>