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-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);
await userEvent.tab();
await expect(canvas.getByRole('link')).toHaveFocus();
await expect(canvas.getByRole('link')).toHaveAttribute('href', '/listings/listing-1');
},
};
@@ -1,4 +1,9 @@
<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 StatusBadge from '@/components/atoms/StatusBadge.vue';
@@ -11,14 +16,14 @@ defineProps({
</script>
<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>
<StatusBadge :status="listing.status" />
<span class="listing-card__name">{{ listing.productName }}</span>
<PriceDisplay :amount-minor="listing.askingPriceMinor" />
</span>
</a>
</RouterLink>
</template>
<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,
};
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 = {
parameters: {
+4 -1
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { usePublicPage } from '@/composables/usePublicPage.js';
import { useCatalogSearch } from "@/composables/useCatalogSearch.js";
import {
listPublicCategories,
@@ -12,6 +13,8 @@ import CatalogSortSelect from "@/components/atoms/CatalogSortSelect.vue";
import CatalogTextFilter from "@/components/atoms/CatalogTextFilter.vue";
import CatalogToggle from "@/components/atoms/CatalogToggle.vue";
const pageRoot = ref(null);
usePublicPage(pageRoot, () => 'Catalog');
const route = useRoute();
const router = useRouter();
const categories = ref([]);
@@ -133,7 +136,7 @@ watch(
</script>
<template>
<main class="catalog-page">
<main ref="pageRoot" class="catalog-page public-page-focus" tabindex="-1" aria-label="Catalog">
<header class="catalog-header">
<p class="catalog-kicker">Pokemon inventory</p>
<h1>Find the next favorite.</h1>
@@ -17,6 +17,8 @@ export const PublicSellerDescription = {
},
play: async ({ canvasElement }) => {
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>
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { RouterLink, useRoute } from "vue-router";
import { getPublicListing } from "@/services/catalog-service.js";
import CollectibleDetail from "@/components/organisms/CollectibleDetail.vue";
import { usePublicPage } from '@/composables/usePublicPage.js';
import { catalogReturnPath } from '@/services/public-navigation.js';
const route = useRoute();
const listing = ref(null);
const isLoading = ref(true);
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 () => {
try {
@@ -22,8 +29,8 @@ onMounted(async () => {
</script>
<template>
<main class="detail-page">
<RouterLink class="detail-back" :to="{ name: 'catalog' }"
<main ref="pageRoot" class="detail-page public-page-focus" tabindex="-1" :aria-label="pageTitle">
<RouterLink class="detail-back" :to="returnPath"
>Back to catalog</RouterLink
>
<p v-if="isLoading" class="catalog-state" role="status">Loading listing...</p>
@@ -17,6 +17,8 @@ export const PublicSellerDescription = {
},
play: async ({ canvasElement }) => {
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>
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { getPublicProduct } from '@/services/catalog-service.js';
import CollectibleDetail from '@/components/organisms/CollectibleDetail.vue';
import { usePublicPage } from '@/composables/usePublicPage.js';
import { catalogReturnPath } from '@/services/public-navigation.js';
const route = useRoute();
const product = ref(null);
const isLoading = ref(true);
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 () => {
try {
@@ -22,8 +29,8 @@ onMounted(async () => {
</script>
<template>
<main class="detail-page">
<RouterLink class="detail-back" :to="{ name: 'catalog' }">Back to catalog</RouterLink>
<main ref="pageRoot" class="detail-page public-page-focus" tabindex="-1" :aria-label="pageTitle">
<RouterLink class="detail-back" :to="returnPath">Back to catalog</RouterLink>
<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
@@ -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 : '/';
}