This commit is contained in:
@@ -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,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', '/');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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', '/');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -29,3 +29,44 @@ Run automated accessibility testing in CI.
|
||||
Serious accessibility regressions should fail CI.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
### Latest Vertical Slice: Public Seller Description
|
||||
### Public Seller Description
|
||||
|
||||
- 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.
|
||||
@@ -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.
|
||||
- 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user