feat: manage focus and announce steps in guided wizard
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-10 06:37:02 -04:00
parent 1fa4bb1c7a
commit 934aad2afd
4 changed files with 55 additions and 13 deletions
+2
View File
@@ -9,6 +9,7 @@ button, input, select, textarea { font: inherit; }
button, a, input, select, textarea { &:focus-visible { outline: var(--border-strong) solid var(--color-focus); outline-offset: 3px; box-shadow: var(--glow-focus); } }
button { cursor: pointer; }
h1, h2, h3, h4 { margin: 0; font-family: var(--font-display); line-height: var(--leading-tight); }
.visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; border: 0; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }
.catalog-page, .detail-page, .showcase-page { min-height: 100vh; background: var(--color-background); }
.catalog-header, .catalog-results { width: min(1120px, calc(100% - var(--space-8))); margin: 0 auto; }
@@ -55,6 +56,7 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
.admin-workspace-grid form { display: grid; gap: var(--space-3); }
.admin-wizard { display: grid; gap: var(--space-4); margin: var(--space-8) 0; }
.admin-wizard__nav { display: flex; justify-content: space-between; gap: var(--space-3); }
.admin-wizard > [role="region"]:focus { outline: var(--border-strong) solid var(--color-focus); outline-offset: 3px; }
.admin-list { margin-top: var(--space-8); }
.admin-list h2 { margin-bottom: var(--space-3); font-size: var(--text-xl); }
.admin-list ul { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
@@ -1,8 +1,27 @@
import { expect, userEvent, within } from "@storybook/test";
import AdminHomePage from "./AdminHomePage.vue";
export default { title: "Pages/AdminHomePage", component: AdminHomePage };
export const Overview = {};
export const WizardStepFocusManagement = {
parameters: {
docs: {
description: {
story:
"Selecting a wizard stage moves focus to that stage's panel and announces the step change for assistive technology.",
},
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: /Sources/ }));
await expect(canvas.getByRole("region", { name: "Sources step" })).toHaveFocus();
await expect(await canvas.findByText("Step 2 of 6: Sources - Link markets")).toBeInTheDocument();
},
};
export const ProviderMappingWorkflow = {
parameters: {
docs: {
+33 -13
View File
@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { computed, nextTick, onMounted, ref } from "vue";
import { useAuth } from "@/composables/useAuth.js";
import InventoryWorkflowStepper from "@/components/molecules/InventoryWorkflowStepper.vue";
@@ -428,10 +428,23 @@ async function overridePricing() {
}
}
const wizardPanels = {};
function setWizardPanelRef(stepId, el) {
wizardPanels[stepId] = el;
}
const activeStepAnnouncement = computed(() => {
const index = activeStepIndex.value;
const step = workflowSteps.value[index];
if (!step) return "";
return `Step ${index + 1} of ${WIZARD_STEP_IDS.length}: ${step.label} - ${step.description}`;
});
function goToStep(stepId) {
if (!WIZARD_STEP_IDS.includes(stepId)) return;
activeStepId.value = stepId;
localStorage.setItem(WIZARD_STORAGE_KEY, stepId);
nextTick(() => wizardPanels[stepId]?.focus());
}
function goToNextStep() {
@@ -586,10 +599,11 @@ onMounted(() => {
</section>
<InventoryWorkflowStepper :steps="workflowSteps" :active-step-id="activeStepId" @select="goToStep" />
<p class="visually-hidden" role="status">{{ activeStepAnnouncement }}</p>
<div class="admin-wizard">
<ProductIdentityStep
v-show="activeStepId === 'workflow-product'"
<div v-show="activeStepId === 'workflow-product'" :ref="(el) => setWizardPanelRef('workflow-product', el)" tabindex="-1" role="region" aria-label="Product step">
<ProductIdentityStep
:product-name="productName"
:category-id="categoryId"
:categories="categories"
@@ -598,8 +612,9 @@ onMounted(() => {
@update:category-id="categoryId = $event"
@submit="createProduct"
/>
<ProviderMappingStep
v-show="activeStepId === 'workflow-mapping'"
</div>
<div v-show="activeStepId === 'workflow-mapping'" :ref="(el) => setWizardPanelRef('workflow-mapping', el)" tabindex="-1" role="region" aria-label="Sources step">
<ProviderMappingStep
:product-id="mappingProductId"
:provider-id="mappingProviderId"
:mapping-type="mappingType"
@@ -615,8 +630,9 @@ onMounted(() => {
@product-change="loadProviderMappings($event)"
@submit="createProviderMapping"
/>
<InventoryCopyStep
v-show="activeStepId === 'workflow-inventory'"
</div>
<div v-show="activeStepId === 'workflow-inventory'" :ref="(el) => setWizardPanelRef('workflow-inventory', el)" tabindex="-1" role="region" aria-label="Copy step">
<InventoryCopyStep
:product-id="inventoryProductId"
:condition-id="conditionId"
:products="products"
@@ -626,13 +642,15 @@ onMounted(() => {
@update:condition-id="conditionId = $event"
@submit="createInventory"
/>
<InventoryImagesStep
v-show="activeStepId === 'workflow-images'"
</div>
<div v-show="activeStepId === 'workflow-images'" :ref="(el) => setWizardPanelRef('workflow-images', el)" tabindex="-1" role="region" aria-label="Images step">
<InventoryImagesStep
:inventory="inventory"
:missing-images="dashboard?.counts.missingImages ?? 0"
/>
<PricingReviewStep
v-show="activeStepId === 'workflow-pricing'"
</div>
<div v-show="activeStepId === 'workflow-pricing'" :ref="(el) => setWizardPanelRef('workflow-pricing', el)" tabindex="-1" role="region" aria-label="Pricing step">
<PricingReviewStep
:inventory-item-id="pricingInventoryId"
:provider="pricingProvider"
:source-identifier="pricingSource"
@@ -652,8 +670,9 @@ onMounted(() => {
@refresh="refreshPricing"
@override="overridePricing"
/>
<ReviewListStep
v-show="activeStepId === 'workflow-listing'"
</div>
<div v-show="activeStepId === 'workflow-listing'" :ref="(el) => setWizardPanelRef('workflow-listing', el)" tabindex="-1" role="region" aria-label="List step">
<ReviewListStep
:inventory-item-id="listingInventoryId"
:asking-price="askingPrice"
:inventory="inventory"
@@ -662,6 +681,7 @@ onMounted(() => {
@update:asking-price="askingPrice = $event"
@submit="createListing"
/>
</div>
<div class="admin-wizard__nav">
<button class="button button--secondary" type="button" :disabled="activeStepIndex === 0" @click="goToPreviousStep">Back</button>
<button class="button button--secondary" type="button" :disabled="activeStepIndex === WIZARD_STEP_IDS.length - 1" @click="goToNextStep">Next</button>
+1
View File
@@ -181,6 +181,7 @@ Remaining roadmap areas are approved external provider credentials/adapters, bro
- The guided inventory workflow is now a true single-step-at-a-time wizard: the stepper is clickable, only the active stage's panel is shown, Back/Next controls advance between stages, and the active stage persists across reloads through local storage.
- Listing management (publish/mark pending) is now a dedicated `/admin/listings` screen with its own navigation entry; the guided workflow overview links to it and only tracks listing creation and a lightweight count for wizard completion.
- Sale recording/voiding and acquisition recording are now dedicated `/admin/sales` and `/admin/acquisitions` screens with their own navigation entries; the guided workflow overview links to both instead of embedding their forms and history inline.
- The guided wizard now manages focus and announces step changes: selecting a stage (via the stepper or Back/Next) moves keyboard/assistive-technology focus to that stage's labeled region and updates a live-region status announcement, with Storybook interaction coverage.
## Local Commands