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"
|
||||
|
||||
Reference in New Issue
Block a user