fix: improve public detail keyboard access and reflow
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-10 07:48:42 -04:00
parent 94c37250d1
commit a02965de4a
15 changed files with 193 additions and 18 deletions
+4 -2
View File
@@ -3,8 +3,10 @@
// headless iframe size that @storybook/test-runner navigates to directly.
export default {
async preVisit(page, context) {
if (context.id?.startsWith('pages-catalogpage--mobile')) {
await page.setViewportSize({ width: 375, height: 900 });
if (context.id?.includes('--enlarged')) {
await page.setViewportSize({ width: 640, height: 900 });
} else if (context.id?.includes('--mobile')) {
await page.setViewportSize({ width: 320, height: 900 });
}
},
};
@@ -2,4 +2,5 @@ import ConditionBadge from './ConditionBadge.vue';
export default { title: 'Atoms/ConditionBadge', component: ConditionBadge };
export const Unselected = { args: { label: 'Near Mint', selected: false } };
export const Selected = { args: { label: 'Near Mint', selected: true } };
export const Selected = { args: { label: 'Near Mint', selected: true } };
export const ReadOnly = { args: { label: 'Near Mint', interactive: false } };
@@ -1,15 +1,17 @@
<script setup>
defineProps({
label: { type: String, required: true },
interactive: { type: Boolean, default: true },
selected: { type: Boolean, default: false },
});
defineEmits(['select']);
</script>
<template>
<button :class="['condition', { 'condition--selected': selected }]" type="button" @click="$emit('select')">
<button v-if="interactive" :class="['condition', { 'condition--selected': selected }]" type="button" @click="$emit('select')">
{{ label }}
</button>
<span v-else class="condition">{{ label }}</span>
</template>
<style scoped lang="scss">
@@ -2,6 +2,6 @@
.image-gallery__stage { display: grid; place-items: center; min-height: 280px; border-radius: var(--radius-xl); background: var(--color-stage); overflow: hidden; }
.image-gallery__stage img { width: 100%; height: 100%; min-height: 280px; object-fit: contain; }
.image-gallery__placeholder { color: var(--color-text-muted); font-family: var(--font-display); font-size: var(--text-2xl); font-weight: var(--weight-bold); }
.image-gallery__controls { display: flex; gap: var(--space-2); }
.image-gallery__controls { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.image-gallery__controls button { min-width: 44px; min-height: 44px; border: var(--border-thin) solid var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-surface); color: var(--color-text); }
.image-gallery__controls button.image-gallery__thumb--selected { border-color: var(--color-interactive); box-shadow: var(--glow-selection); color: var(--color-interactive-text); }
@@ -1,3 +1,4 @@
import { expect, within } from '@storybook/test';
import ImageGallery from "./ImageGallery.vue";
export default { title: "Molecules/ImageGallery", component: ImageGallery };
@@ -47,3 +48,10 @@ export const LoadingImage = {
},
parameters: { chromatic: { delay: 1000 } },
};
export const BrokenApiImageFallback = {
args: { images: [{ id: 'broken-api', url: '/fixtures/missing-api-image.webp', altText: 'Back of collectible' }] },
play: async ({ canvasElement }) => {
await expect(await within(canvasElement).findByRole('img', { name: 'Collectible image unavailable' })).toBeVisible();
},
};
@@ -69,12 +69,12 @@ onMounted(() => markLoading(props.images[selectedIndex.value]));
class="image-gallery__placeholder"
role="img"
:aria-label="
props.images[selectedIndex]?.src
(props.images[selectedIndex]?.src ?? props.images[selectedIndex]?.url)
? 'Collectible image unavailable'
: 'No collectible image available'
"
>{{
props.images[selectedIndex]?.src
(props.images[selectedIndex]?.src ?? props.images[selectedIndex]?.url)
? "IMAGE UNAVAILABLE"
: (props.images[selectedIndex]?.label ?? "IMAGE")
}}</span
@@ -83,6 +83,7 @@ onMounted(() => markLoading(props.images[selectedIndex.value]));
<div
v-if="props.images.length > 1"
class="image-gallery__controls"
role="group"
aria-label="Gallery images"
>
<button
@@ -90,7 +91,7 @@ onMounted(() => markLoading(props.images[selectedIndex.value]));
:key="image.id"
:class="{ 'image-gallery__thumb--selected': index === selectedIndex }"
type="button"
:aria-label="`Show image ${index + 1}: ${image.alt}`"
:aria-label="`Show image ${index + 1}: ${image.altText || image.alt || image.label || 'Collectible image'}`"
:aria-current="index === selectedIndex ? 'true' : undefined"
@click="
selectedIndex = index;
@@ -1,4 +1,4 @@
import { expect, within } from '@storybook/test';
import { expect, userEvent, within } from '@storybook/test';
import CollectibleDetail from "./CollectibleDetail.vue";
export default {
@@ -68,3 +68,43 @@ export const SellerDescriptionDark = {
export const PendingSellerDescriptionDark = { ...SellerDescriptionDark, args: { ...ListingDetail.args, status: 'PENDING' } };
export const SoldSellerDescriptionDark = { ...SellerDescriptionDark, args: { ...ListingDetail.args, status: 'SOLD' } };
export const MobileSellerContact = {
args: {
...ListingDetail.args,
title: 'Charizard-ex-Special-Illustration-Rare-Collector-Edition',
sellerContactEmail: 'collectibles-and-trading-cards@independent-collector.example',
images: Array.from({ length: 10 }, (_, index) => ({ id: `copy-${index}`, altText: `Collectible view ${index + 1}`, label: `VIEW ${index + 1}` })),
},
decorators: [() => ({ template: '<main style="max-width: 100%; padding: 16px; background: var(--color-background); color: var(--color-text)"><story /></main>' })],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const first = canvas.getByRole('button', { name: 'Show image 1: Collectible view 1' });
const second = canvas.getByRole('button', { name: 'Show image 2: Collectible view 2' });
first.focus();
await userEvent.tab();
await expect(second).toHaveFocus();
await userEvent.keyboard('{Enter}');
await expect(second).toHaveAttribute('aria-current', 'true');
await expect(first).not.toHaveAttribute('aria-current');
await expect(canvas.queryByRole('button', { name: 'Near Mint' })).toBeNull();
for (let index = 0; index < 9; index += 1) await userEvent.tab();
const email = canvas.getByRole('link', { name: /collectibles-and-trading-cards@/ });
await expect(email).toHaveFocus();
await expect(email).toHaveAttribute('href', `mailto:${MobileSellerContact.args.sellerContactEmail}`);
await userEvent.tab();
await expect(canvas.getByRole('link', { name: '555-0100' })).toHaveFocus();
await expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(window.innerWidth);
},
};
// CSS text resizing complements the 640px viewport (1280px at 200% zoom).
export const EnlargedSellerContact = {
...MobileSellerContact,
beforeEach: () => {
const original = document.documentElement.style.fontSize;
document.documentElement.style.fontSize = '200%';
return () => { document.documentElement.style.fontSize = original; };
},
};
@@ -39,7 +39,7 @@ defineProps({
label="Market value"
:amount-minor="marketValueMinor"
/>
<ConditionBadge v-if="condition" :label="condition" />
<ConditionBadge v-if="condition" :label="condition" :interactive="false" />
</div>
<p v-if="marketValueUpdatedAt" class="collectible-detail__market-updated">
Market value updated
@@ -70,6 +70,11 @@ defineProps({
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--space-12);
align-items: start;
overflow-wrap: anywhere;
}
.collectible-detail__gallery,
.collectible-detail__identity {
min-width: 0;
}
.collectible-detail__identity {
display: grid;
+26 -2
View File
@@ -26,14 +26,38 @@ export const MobileFilterDisclosure = {
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(setFilter).not.toBeVisible();
await userEvent.click(toggle);
toggle.focus();
await userEvent.keyboard('{Enter}');
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await expect(setFilter).toBeVisible();
await userEvent.click(toggle);
toggle.focus();
await userEvent.keyboard(' ');
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(setFilter).not.toBeVisible();
},
};
export const MobilePopulatedCatalog = {
beforeEach: () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url) => new Response(JSON.stringify({ data: String(url).includes('/search?') ? {
items: [{ id: 'collectible-1', productName: 'Charizard ex special illustration rare', askingPriceMinor: 2500, status: 'AVAILABLE' }],
pagination: { page: 1, limit: 20, total: 1, totalPages: 1 },
} : [] }), { headers: { 'content-type': 'application/json' } });
return () => { globalThis.fetch = originalFetch; };
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const listing = await canvas.findByRole('link', { name: /Charizard ex/ });
const toggle = canvas.getByRole('button', { name: /Filters/ });
toggle.focus();
await userEvent.tab();
await expect(listing).toHaveFocus();
await expect(listing).toHaveAttribute('href', '/listings/collectible-1');
await expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(window.innerWidth);
},
};
@@ -19,3 +19,25 @@ export const PublicSellerDescription = {
await expect(await within(canvasElement).findByText('Independent Pokémon collector.')).toBeVisible();
},
};
export const Loading = {
beforeEach: () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = () => new Promise(() => {});
return () => { globalThis.fetch = originalFetch; };
},
play: async ({ canvasElement }) => {
await expect(within(canvasElement).getByRole('status')).toHaveTextContent('Loading');
},
};
export const Unavailable = {
beforeEach: () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(JSON.stringify({ error: { message: 'This collectible is unavailable.' } }), { status: 404 });
return () => { globalThis.fetch = originalFetch; };
},
play: async ({ canvasElement }) => {
await expect(await within(canvasElement).findByRole('alert')).toHaveTextContent('Listing not found.');
await expect(within(canvasElement).getByRole('link', { name: 'Back to catalog' })).toHaveAttribute('href', '/');
},
};
+2 -2
View File
@@ -26,8 +26,8 @@ onMounted(async () => {
<RouterLink class="detail-back" :to="{ name: 'catalog' }"
>Back to catalog</RouterLink
>
<p v-if="isLoading" class="catalog-state">Loading listing...</p>
<p v-else-if="error" class="catalog-state catalog-state--error">
<p v-if="isLoading" class="catalog-state" role="status">Loading listing...</p>
<p v-else-if="error" class="catalog-state catalog-state--error" role="alert">
{{ error.message }}
</p>
<CollectibleDetail
@@ -19,3 +19,25 @@ export const PublicSellerDescription = {
await expect(await within(canvasElement).findByText('Independent Pokémon collector.')).toBeVisible();
},
};
export const Loading = {
beforeEach: () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = () => new Promise(() => {});
return () => { globalThis.fetch = originalFetch; };
},
play: async ({ canvasElement }) => {
await expect(within(canvasElement).getByRole('status')).toHaveTextContent('Loading');
},
};
export const Unavailable = {
beforeEach: () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(JSON.stringify({ error: { message: 'This collectible is unavailable.' } }), { status: 404 });
return () => { globalThis.fetch = originalFetch; };
},
play: async ({ canvasElement }) => {
await expect(await within(canvasElement).findByRole('alert')).toHaveTextContent('Product not found.');
await expect(within(canvasElement).getByRole('link', { name: 'Back to catalog' })).toHaveAttribute('href', '/');
},
};
+2 -2
View File
@@ -24,8 +24,8 @@ onMounted(async () => {
<template>
<main class="detail-page">
<RouterLink class="detail-back" :to="{ name: 'catalog' }">Back to catalog</RouterLink>
<p v-if="isLoading" class="catalog-state">Loading product...</p>
<p v-else-if="error" class="catalog-state catalog-state--error">{{ error.message }}</p>
<p v-if="isLoading" class="catalog-state" role="status">Loading product...</p>
<p v-else-if="error" class="catalog-state catalog-state--error" role="alert">{{ error.message }}</p>
<CollectibleDetail
v-else-if="product"
:title="product.name"