feat: preserve focus and catalog context during public navigation
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-10 07:53:03 -04:00
parent a02965de4a
commit 73c430b8a5
15 changed files with 175 additions and 12 deletions
+2
View File
@@ -124,3 +124,5 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
.admin-shell__content { padding: var(--space-6); } .admin-shell__content { padding: var(--space-6); }
.admin-workspace-grid { grid-template-columns: 1fr; } .admin-workspace-grid { grid-template-columns: 1fr; }
} }
.public-page-focus:focus-visible { outline: var(--border-strong) solid var(--color-focus); outline-offset: -3px; }
@@ -43,5 +43,6 @@ export const KeyboardReachable = {
const canvas = within(canvasElement); const canvas = within(canvasElement);
await userEvent.tab(); await userEvent.tab();
await expect(canvas.getByRole('link')).toHaveFocus(); await expect(canvas.getByRole('link')).toHaveFocus();
await expect(canvas.getByRole('link')).toHaveAttribute('href', '/listings/listing-1');
}, },
}; };
@@ -1,4 +1,9 @@
<script setup> <script setup>
import { computed } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
const route = useRoute();
const catalogOrigin = computed(() => route.name === 'catalog' && route.fullPath !== '/' ? route.fullPath : undefined);
import PriceDisplay from '@/components/atoms/PriceDisplay.vue'; import PriceDisplay from '@/components/atoms/PriceDisplay.vue';
import StatusBadge from '@/components/atoms/StatusBadge.vue'; import StatusBadge from '@/components/atoms/StatusBadge.vue';
@@ -11,14 +16,14 @@ defineProps({
</script> </script>
<template> <template>
<a class="listing-card" :href="`/listings/${encodeURIComponent(listing.id)}`"> <RouterLink class="listing-card" :to="{ name: 'listing-detail', params: { id: listing.id }, query: catalogOrigin ? { from: catalogOrigin } : {} }">
<span class="listing-card__mark" aria-hidden="true">PK</span> <span class="listing-card__mark" aria-hidden="true">PK</span>
<span> <span>
<StatusBadge :status="listing.status" /> <StatusBadge :status="listing.status" />
<span class="listing-card__name">{{ listing.productName }}</span> <span class="listing-card__name">{{ listing.productName }}</span>
<PriceDisplay :amount-minor="listing.askingPriceMinor" /> <PriceDisplay :amount-minor="listing.askingPriceMinor" />
</span> </span>
</a> </RouterLink>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
+10
View File
@@ -0,0 +1,10 @@
import { onMounted, watch } from 'vue';
// Focus only on entry. Async results and query-string changes must not steal
// focus from someone who has already started using the page.
export function usePublicPage(root, title) {
watch(title, (value) => {
document.title = `${value} | Pokémon Inventory`;
}, { immediate: true });
onMounted(() => root.value?.focus());
}
+6 -1
View File
@@ -7,7 +7,12 @@ export default {
component: CatalogPage, component: CatalogPage,
}; };
export const Overview = {}; export const Overview = {
play: async ({ canvasElement }) => {
await expect(document.title).toBe('Catalog | Pokémon Inventory');
await expect(within(canvasElement).getByRole('main')).toHaveFocus();
},
};
export const MobileFilterDisclosure = { export const MobileFilterDisclosure = {
parameters: { parameters: {
+4 -1
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { usePublicPage } from '@/composables/usePublicPage.js';
import { useCatalogSearch } from "@/composables/useCatalogSearch.js"; import { useCatalogSearch } from "@/composables/useCatalogSearch.js";
import { import {
listPublicCategories, listPublicCategories,
@@ -12,6 +13,8 @@ import CatalogSortSelect from "@/components/atoms/CatalogSortSelect.vue";
import CatalogTextFilter from "@/components/atoms/CatalogTextFilter.vue"; import CatalogTextFilter from "@/components/atoms/CatalogTextFilter.vue";
import CatalogToggle from "@/components/atoms/CatalogToggle.vue"; import CatalogToggle from "@/components/atoms/CatalogToggle.vue";
const pageRoot = ref(null);
usePublicPage(pageRoot, () => 'Catalog');
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const categories = ref([]); const categories = ref([]);
@@ -133,7 +136,7 @@ watch(
</script> </script>
<template> <template>
<main class="catalog-page"> <main ref="pageRoot" class="catalog-page public-page-focus" tabindex="-1" aria-label="Catalog">
<header class="catalog-header"> <header class="catalog-header">
<p class="catalog-kicker">Pokemon inventory</p> <p class="catalog-kicker">Pokemon inventory</p>
<h1>Find the next favorite.</h1> <h1>Find the next favorite.</h1>
@@ -17,6 +17,8 @@ export const PublicSellerDescription = {
}, },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
await expect(await within(canvasElement).findByText('Independent Pokémon collector.')).toBeVisible(); await expect(await within(canvasElement).findByText('Independent Pokémon collector.')).toBeVisible();
await expect(document.title).toBe('Pikachu | Pokémon Inventory');
await expect(within(canvasElement).getByRole('main')).toHaveFocus();
}, },
}; };
+10 -3
View File
@@ -1,14 +1,21 @@
<script setup> <script setup>
import { onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { RouterLink, useRoute } from "vue-router"; import { RouterLink, useRoute } from "vue-router";
import { getPublicListing } from "@/services/catalog-service.js"; import { getPublicListing } from "@/services/catalog-service.js";
import CollectibleDetail from "@/components/organisms/CollectibleDetail.vue"; import CollectibleDetail from "@/components/organisms/CollectibleDetail.vue";
import { usePublicPage } from '@/composables/usePublicPage.js';
import { catalogReturnPath } from '@/services/public-navigation.js';
const route = useRoute(); const route = useRoute();
const listing = ref(null); const listing = ref(null);
const isLoading = ref(true); const isLoading = ref(true);
const error = ref(null); const error = ref(null);
const pageRoot = ref(null);
const pageTitle = computed(() => error.value?.message ?? listing.value?.productName ?? 'Listing detail');
const returnPath = computed(() => catalogReturnPath(route.query.from));
usePublicPage(pageRoot, pageTitle);
onMounted(async () => { onMounted(async () => {
try { try {
@@ -22,8 +29,8 @@ onMounted(async () => {
</script> </script>
<template> <template>
<main class="detail-page"> <main ref="pageRoot" class="detail-page public-page-focus" tabindex="-1" :aria-label="pageTitle">
<RouterLink class="detail-back" :to="{ name: 'catalog' }" <RouterLink class="detail-back" :to="returnPath"
>Back to catalog</RouterLink >Back to catalog</RouterLink
> >
<p v-if="isLoading" class="catalog-state" role="status">Loading listing...</p> <p v-if="isLoading" class="catalog-state" role="status">Loading listing...</p>
@@ -17,6 +17,8 @@ export const PublicSellerDescription = {
}, },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
await expect(await within(canvasElement).findByText('Independent Pokémon collector.')).toBeVisible(); await expect(await within(canvasElement).findByText('Independent Pokémon collector.')).toBeVisible();
await expect(document.title).toBe('Pikachu | Pokémon Inventory');
await expect(within(canvasElement).getByRole('main')).toHaveFocus();
}, },
}; };
+10 -3
View File
@@ -1,14 +1,21 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { RouterLink, useRoute } from 'vue-router'; import { RouterLink, useRoute } from 'vue-router';
import { getPublicProduct } from '@/services/catalog-service.js'; import { getPublicProduct } from '@/services/catalog-service.js';
import CollectibleDetail from '@/components/organisms/CollectibleDetail.vue'; import CollectibleDetail from '@/components/organisms/CollectibleDetail.vue';
import { usePublicPage } from '@/composables/usePublicPage.js';
import { catalogReturnPath } from '@/services/public-navigation.js';
const route = useRoute(); const route = useRoute();
const product = ref(null); const product = ref(null);
const isLoading = ref(true); const isLoading = ref(true);
const error = ref(null); const error = ref(null);
const pageRoot = ref(null);
const pageTitle = computed(() => error.value?.message ?? product.value?.name ?? 'Product detail');
const returnPath = computed(() => catalogReturnPath(route.query.from));
usePublicPage(pageRoot, pageTitle);
onMounted(async () => { onMounted(async () => {
try { try {
@@ -22,8 +29,8 @@ onMounted(async () => {
</script> </script>
<template> <template>
<main class="detail-page"> <main ref="pageRoot" class="detail-page public-page-focus" tabindex="-1" :aria-label="pageTitle">
<RouterLink class="detail-back" :to="{ name: 'catalog' }">Back to catalog</RouterLink> <RouterLink class="detail-back" :to="returnPath">Back to catalog</RouterLink>
<p v-if="isLoading" class="catalog-state" role="status">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" role="alert">{{ error.message }}</p> <p v-else-if="error" class="catalog-state catalog-state--error" role="alert">{{ error.message }}</p>
<CollectibleDetail <CollectibleDetail
@@ -0,0 +1,5 @@
export function catalogReturnPath(value) {
// Only a catalog URL is a valid return destination, never another origin or
// an admin/detail route. Keep its encoded filters and pagination intact.
return typeof value === 'string' && /^\/(?:\?|$)/.test(value) ? value : '/';
}
@@ -0,0 +1,77 @@
import { expect, test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.route('**/api/v1/public/**', async (route) => {
const path = new URL(route.request().url()).pathname;
if (path.includes('/lookups/')) return route.fulfill({ json: { data: [] } });
if (path.endsWith('/search')) return route.fulfill({ json: { data: {
items: [{ id: 'listing-1', productName: 'Pikachu ex', askingPriceMinor: 2500, status: 'AVAILABLE' }],
pagination: { page: 1, limit: 20, total: 1, totalPages: 1 },
} } });
if (path.endsWith('/missing')) return route.fulfill({ status: 404, json: {} });
return route.fulfill({ json: { data: {
id: 'listing-1', name: 'Pikachu ex', productName: 'Pikachu ex', askingPriceMinor: 2500,
status: 'AVAILABLE', images: [], sellerDisplayName: 'Pika Collectibles',
sellerContactEmail: 'collector@example.test',
} } });
});
});
test('keyboard navigation retains catalog filters, titles and entry focus', async ({ page }) => {
await page.goto('/?q=Pikachu&status=AVAILABLE');
await expect(page).toHaveTitle('Catalog | Pokémon Inventory');
await expect(page.getByRole('main')).toBeFocused();
const search = page.getByRole('searchbox', { name: 'Search the catalog' });
await page.keyboard.press('Tab');
await expect(search).toBeFocused();
await search.fill('Pikachu ex');
await expect(page).toHaveURL(/q=Pikachu(?:\+|%20)ex/);
await expect(search).toBeFocused();
const catalogUrl = page.url();
const card = page.getByRole('link', { name: /Pikachu ex/ });
await card.focus();
await page.keyboard.press('Enter');
await expect(page).toHaveTitle('Pikachu ex | Pokémon Inventory');
await expect(page.getByRole('main')).toBeFocused();
await page.keyboard.press('Tab');
const back = page.getByRole('link', { name: 'Back to catalog' });
await expect(back).toBeFocused();
await page.keyboard.press('Enter');
await expect(page).toHaveURL(catalogUrl);
await expect(search).toHaveValue('Pikachu ex');
await expect(page.getByRole('main')).toBeFocused();
await page.goBack();
await expect(page).toHaveTitle('Pikachu ex | Pokémon Inventory');
await expect(page.getByRole('main')).toBeFocused();
await page.goBack();
await expect(page).toHaveURL(catalogUrl);
await expect(search).toHaveValue('Pikachu ex');
});
test('direct product and unavailable pages have titles, focus and safe return links', async ({ page }) => {
await page.goto('/products/pikachu?from=https://example.test');
await expect(page).toHaveTitle('Pikachu ex | Pokémon Inventory');
await expect(page.getByRole('main')).toBeFocused();
await expect(page.getByRole('link', { name: 'Back to catalog' })).toHaveAttribute('href', '/');
await page.goto('/listings/missing');
await expect(page).toHaveTitle('Listing not found. | Pokémon Inventory');
await expect(page.getByRole('alert')).toHaveText('Listing not found.');
await expect(page.getByRole('main')).toBeFocused();
});
test('late detail results do not steal focus from the return link', async ({ page }) => {
let release;
const responseReady = new Promise((resolve) => { release = resolve; });
await page.route('**/api/v1/public/products/slow', async (route) => {
await responseReady;
await route.fulfill({ json: { data: { name: 'Slow Pikachu', images: [] } } });
});
await page.goto('/products/slow');
await expect(page.getByRole('status')).toHaveText('Loading product...');
await page.keyboard.press('Tab');
const back = page.getByRole('link', { name: 'Back to catalog' });
await expect(back).toBeFocused();
release();
await expect(page).toHaveTitle('Slow Pikachu | Pokémon Inventory');
await expect(back).toBeFocused();
});
+10
View File
@@ -0,0 +1,10 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { catalogReturnPath } from '../src/services/public-navigation.js';
test('retains catalog filters and rejects external or non-catalog return URLs', () => {
assert.equal(catalogReturnPath('/?q=Pikachu%20ex&page=2'), '/?q=Pikachu%20ex&page=2');
for (const value of [undefined, ['/?q=a'], '//example.test', '/\\example.test', 'https://example.test', '/admin', '/listings/1']) {
assert.equal(catalogReturnPath(value), '/');
}
});
+19
View File
@@ -70,3 +70,22 @@ Remaining manual coverage: native browser zoom, screen-reader speech and route
announcements (VoiceOver/Safari or NVDA/Firefox), and the full live-data navigation announcements (VoiceOver/Safari or NVDA/Firefox), and the full live-data navigation
journey. Automated checks and accessibility-tree inspection do not certify WCAG journey. Automated checks and accessibility-tree inspection do not certify WCAG
conformance or replace those reviews. conformance or replace those reviews.
## Public route navigation — 2026-09-10
- Catalog cards use router links with ordinary URL/open-in-new-tab behavior.
- Catalog and public detail pages set descriptive browser titles. Detail titles
update to the product name or error message after loading.
- Entry focuses the named main landmark with a visible keyboard outline. It is
programmatically focusable (`tabindex=-1`) without adding an extra Tab stop.
Query updates and asynchronous results do not move focus again.
- Filtered catalog links carry their origin in `from`; “Back to catalog” restores
that URL. Only `/` and catalog query URLs are accepted; external or non-catalog
destinations fall back to `/`. Browser Back retains its normal history behavior.
- Three public Playwright tests exercise keyboard navigation, query preservation,
browser Back, direct product links, not-found titles, safe return URLs, and a
delayed response while focus remains on the return link. They intercept public
API responses and do not require credentials or mutate production data.
These checks verify browser focus, titles, and navigation behavior. Screen-reader
speech and native browser zoom remain manual follow-ups from the earlier review.
+10 -2
View File
@@ -131,14 +131,22 @@ 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).
### Latest Vertical Slice: Public Browsing Accessibility ### 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. - 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. - 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`. - 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. - 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. ### Latest Vertical Slice: Public Navigation Accessibility
- Catalog cards use router links; catalog and detail pages have descriptive titles and named, focused main landmarks on entry.
- Async responses and query updates preserve user focus. Filtered catalog URLs survive the explicit return link and browser Back; return destinations are restricted to the catalog.
- Added three public browser navigation tests and return-path validation coverage; updated page/card Storybook interactions.
- Validation: 166 API tests, 11 web tests, 3 public Playwright tests, production web build, Storybook build, and 32 Storybook suites / 93 tests. No API or persistence changes.
- Screen-reader speech and native browser zoom remain manual review items; this slice verifies browser focus and title behavior.
Next bounded slice: clearing configured seller contact methods. The Settings form currently converts blank email/phone values to omitted fields, so the server's partial-update merge retains old contacts. Support explicit removal through validation, settings UI/API, and public detail output, with edit/clear regression coverage.
## Next Milestone ## Next Milestone