feat: preserve focus and catalog context during public navigation
Quality / test (push) Canceled after 0s
Quality / test (push) Canceled after 0s
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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 : '/';
|
||||
}
|
||||
Reference in New Issue
Block a user