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. // headless iframe size that @storybook/test-runner navigates to directly.
export default { export default {
async preVisit(page, context) { async preVisit(page, context) {
if (context.id?.startsWith('pages-catalogpage--mobile')) { if (context.id?.includes('--enlarged')) {
await page.setViewportSize({ width: 375, height: 900 }); await page.setViewportSize({ width: 640, height: 900 });
} else if (context.id?.includes('--mobile')) {
await page.setViewportSize({ width: 320, height: 900 });
} }
}, },
}; };
@@ -3,3 +3,4 @@ import ConditionBadge from './ConditionBadge.vue';
export default { title: 'Atoms/ConditionBadge', component: ConditionBadge }; export default { title: 'Atoms/ConditionBadge', component: ConditionBadge };
export const Unselected = { args: { label: 'Near Mint', selected: false } }; 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> <script setup>
defineProps({ defineProps({
label: { type: String, required: true }, label: { type: String, required: true },
interactive: { type: Boolean, default: true },
selected: { type: Boolean, default: false }, selected: { type: Boolean, default: false },
}); });
defineEmits(['select']); defineEmits(['select']);
</script> </script>
<template> <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 }} {{ label }}
</button> </button>
<span v-else class="condition">{{ label }}</span>
</template> </template>
<style scoped lang="scss"> <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 { 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__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__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 { 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); } .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"; import ImageGallery from "./ImageGallery.vue";
export default { title: "Molecules/ImageGallery", component: ImageGallery }; export default { title: "Molecules/ImageGallery", component: ImageGallery };
@@ -47,3 +48,10 @@ export const LoadingImage = {
}, },
parameters: { chromatic: { delay: 1000 } }, 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" class="image-gallery__placeholder"
role="img" role="img"
:aria-label=" :aria-label="
props.images[selectedIndex]?.src (props.images[selectedIndex]?.src ?? props.images[selectedIndex]?.url)
? 'Collectible image unavailable' ? 'Collectible image unavailable'
: 'No collectible image available' : 'No collectible image available'
" "
>{{ >{{
props.images[selectedIndex]?.src (props.images[selectedIndex]?.src ?? props.images[selectedIndex]?.url)
? "IMAGE UNAVAILABLE" ? "IMAGE UNAVAILABLE"
: (props.images[selectedIndex]?.label ?? "IMAGE") : (props.images[selectedIndex]?.label ?? "IMAGE")
}}</span }}</span
@@ -83,6 +83,7 @@ onMounted(() => markLoading(props.images[selectedIndex.value]));
<div <div
v-if="props.images.length > 1" v-if="props.images.length > 1"
class="image-gallery__controls" class="image-gallery__controls"
role="group"
aria-label="Gallery images" aria-label="Gallery images"
> >
<button <button
@@ -90,7 +91,7 @@ onMounted(() => markLoading(props.images[selectedIndex.value]));
:key="image.id" :key="image.id"
:class="{ 'image-gallery__thumb--selected': index === selectedIndex }" :class="{ 'image-gallery__thumb--selected': index === selectedIndex }"
type="button" 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" :aria-current="index === selectedIndex ? 'true' : undefined"
@click=" @click="
selectedIndex = index; selectedIndex = index;
@@ -1,4 +1,4 @@
import { expect, within } from '@storybook/test'; import { expect, userEvent, within } from '@storybook/test';
import CollectibleDetail from "./CollectibleDetail.vue"; import CollectibleDetail from "./CollectibleDetail.vue";
export default { export default {
@@ -68,3 +68,43 @@ export const SellerDescriptionDark = {
export const PendingSellerDescriptionDark = { ...SellerDescriptionDark, args: { ...ListingDetail.args, status: 'PENDING' } }; export const PendingSellerDescriptionDark = { ...SellerDescriptionDark, args: { ...ListingDetail.args, status: 'PENDING' } };
export const SoldSellerDescriptionDark = { ...SellerDescriptionDark, args: { ...ListingDetail.args, status: 'SOLD' } }; 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" label="Market value"
:amount-minor="marketValueMinor" :amount-minor="marketValueMinor"
/> />
<ConditionBadge v-if="condition" :label="condition" /> <ConditionBadge v-if="condition" :label="condition" :interactive="false" />
</div> </div>
<p v-if="marketValueUpdatedAt" class="collectible-detail__market-updated"> <p v-if="marketValueUpdatedAt" class="collectible-detail__market-updated">
Market value updated Market value updated
@@ -70,6 +70,11 @@ defineProps({
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--space-12); gap: var(--space-12);
align-items: start; align-items: start;
overflow-wrap: anywhere;
}
.collectible-detail__gallery,
.collectible-detail__identity {
min-width: 0;
} }
.collectible-detail__identity { .collectible-detail__identity {
display: grid; display: grid;
+26 -2
View File
@@ -26,14 +26,38 @@ export const MobileFilterDisclosure = {
await expect(toggle).toHaveAttribute('aria-expanded', 'false'); await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(setFilter).not.toBeVisible(); await expect(setFilter).not.toBeVisible();
await userEvent.click(toggle); toggle.focus();
await userEvent.keyboard('{Enter}');
await expect(toggle).toHaveAttribute('aria-expanded', 'true'); await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await expect(setFilter).toBeVisible(); await expect(setFilter).toBeVisible();
await userEvent.click(toggle); toggle.focus();
await userEvent.keyboard(' ');
await expect(toggle).toHaveAttribute('aria-expanded', 'false'); await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(setFilter).not.toBeVisible(); 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(); 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' }" <RouterLink class="detail-back" :to="{ name: 'catalog' }"
>Back to catalog</RouterLink >Back to catalog</RouterLink
> >
<p v-if="isLoading" class="catalog-state">Loading listing...</p> <p v-if="isLoading" class="catalog-state" role="status">Loading listing...</p>
<p v-else-if="error" class="catalog-state catalog-state--error"> <p v-else-if="error" class="catalog-state catalog-state--error" role="alert">
{{ error.message }} {{ error.message }}
</p> </p>
<CollectibleDetail <CollectibleDetail
@@ -19,3 +19,25 @@ export const PublicSellerDescription = {
await expect(await within(canvasElement).findByText('Independent Pokémon collector.')).toBeVisible(); 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> <template>
<main class="detail-page"> <main class="detail-page">
<RouterLink class="detail-back" :to="{ name: 'catalog' }">Back to catalog</RouterLink> <RouterLink class="detail-back" :to="{ name: 'catalog' }">Back to catalog</RouterLink>
<p v-if="isLoading" class="catalog-state">Loading product...</p> <p v-if="isLoading" class="catalog-state" role="status">Loading product...</p>
<p v-else-if="error" class="catalog-state catalog-state--error">{{ error.message }}</p> <p v-else-if="error" class="catalog-state catalog-state--error" role="alert">{{ error.message }}</p>
<CollectibleDetail <CollectibleDetail
v-else-if="product" v-else-if="product"
:title="product.name" :title="product.name"
+41
View File
@@ -29,3 +29,44 @@ Run automated accessibility testing in CI.
Serious accessibility regressions should fail CI. Serious accessibility regressions should fail CI.
Automated tests do not replace manual keyboard and assistive-technology review for important workflows. Automated tests do not replace manual keyboard and assistive-technology review for important workflows.
## Public browsing review — 2026-09-10
Scope: catalog filters and listing links, listing/product detail states, image
selection, condition display, and seller contact links. Fixtures use public DTO
shapes, including `url`/`altText`, ten images, and a long seller email.
Findings fixed:
- Gallery buttons now use API `altText`, with legacy `alt` and label fallbacks,
rather than announcing “undefined”. Controls form a named group and wrap.
- Failed API `url` images now announce an unavailable image rather than claiming
no image exists.
- Detail text and grid children reflow without forcing horizontal scrolling;
long seller contact addresses remain readable and focusable.
- Read-only detail conditions are text, removing a button that had no action.
- Listing and product loading/error messages expose status/alert roles.
Browser review: Codex in-app browser, keyboard and accessibility-tree inspection,
with screenshots checked at a 320px viewport. Tab/Enter select gallery images,
selection remains indicated, Tab reaches email then phone without a condition
button detour, and focus outlines remain visible. The populated catalog's filter
disclosure opens via Enter; expanded filters and the listing card fit at 320px.
Contact targets were inspected without launching email or telephone applications.
Enlargement review: 640px viewport (the CSS layout width of a 1280px browser at
200% zoom), additionally with root text enlarged to 200%. Detail text, gallery
controls, and seller links remain readable and keyboard-accessible without
horizontal scrolling. This is reflow/text-resize coverage, not a native browser
zoom result: browser-zoom shortcuts were unavailable in the review browser.
Regression coverage: mobile catalog filter keyboard interaction and listing
focus/link target; gallery selection and contact focus order; narrow and enlarged
reflow assertions; read-only condition story; broken API image fallback; and
loading/not-found announcements on both public detail pages. Existing light/dark,
empty-image, sold, and pending stories remain part of the accessibility gate.
Remaining manual coverage: native browser zoom, screen-reader speech and route
announcements (VoiceOver/Safari or NVDA/Firefox), and the full live-data navigation
journey. Automated checks and accessibility-tree inspection do not certify WCAG
conformance or replace those reviews.
+9 -2
View File
@@ -122,7 +122,7 @@ docker context use orbstack
Verified locally: `npm test` passes 166 tests with no failures (23 domain, 79 application, and 64 HTTP); worker tests, web tests, web build, Storybook build, Storybook interaction/accessibility checks, integration tests, and authenticated E2E workflows also pass when their required local services and credentials are available. Verified locally: `npm test` passes 166 tests with no failures (23 domain, 79 application, and 64 HTTP); worker tests, web tests, web build, Storybook build, Storybook interaction/accessibility checks, integration tests, and authenticated E2E workflows also pass when their required local services and credentials are available.
### Latest Vertical Slice: Public Seller Description ### Public Seller Description
- Sellers can save, edit, and clear an optional public description in Settings. - Sellers can save, edit, and clear an optional public description in Settings.
- Descriptions are trimmed plain text, limited to 2,000 characters server-side, and stored in existing seller settings JSON without a migration. - Descriptions are trimmed plain text, limited to 2,000 characters server-side, and stored in existing seller settings JSON without a migration.
@@ -131,7 +131,14 @@ Verified locally: `npm test` passes 166 tests with no failures (23 domain, 79 ap
- Coverage includes validation/partial-update/clearing tests, public HTTP contracts, PostgreSQL repository reads, settings edit/clear interactions, both public page integrations, and absent/plain-text/dark-theme component stories. - Coverage includes validation/partial-update/clearing tests, public HTTP contracts, PostgreSQL repository reads, settings edit/clear interactions, both public page integrations, and absent/plain-text/dark-theme component stories.
- Validation: 166 API tests, 23 isolated PostgreSQL/Redis integration tests, 10 web tests, production web build, Storybook build, and Storybook interaction/accessibility runner (32 suites / 84 tests). - Validation: 166 API tests, 23 isolated PostgreSQL/Redis integration tests, 10 web tests, production web build, Storybook build, and Storybook interaction/accessibility runner (32 suites / 84 tests).
Next bounded slice: review the public catalog → listing/product detail → seller contact journey with keyboard navigation, mobile layout, and 200% zoom; fix discovered accessibility issues and add regression stories/tests. Record manual results in `docs/ACCESSIBILITY.md`. This advances the documented accessibility milestone without requiring external provider credentials. ### Latest Vertical Slice: Public Browsing Accessibility
- Public gallery labels use API image descriptions, controls wrap at narrow widths, and failed API image URLs have an accurate accessible fallback.
- Long detail text and seller contacts reflow, read-only condition badges no longer add inactive buttons to keyboard navigation, and detail loading/errors expose announcement roles.
- Added keyboard, 320px reflow, 200% text enlargement, and detail-state regression stories. Browser review and its native-zoom/screen-reader limitations are recorded in `docs/ACCESSIBILITY.md`.
- Validation: 166 API tests, 10 web tests, production web build, Storybook build, and 32 Storybook suites / 93 tests. This frontend-only slice changes no persistence or API contracts.
Next bounded slice: public route navigation accessibility. Review catalog → listing/product → back navigation with a screen reader, add appropriate page titles and route-focus handling where missing, and cover navigation in browser tests. Include native 200% zoom review.
## Next Milestone ## Next Milestone