diff --git a/CHANGELOG.md b/CHANGELOG.md index 516c7e78..1a5f31b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ [UNRELEASED] +### Patch Changes + +- ♿️(onboarding) improve OnboardingModal keyboard, screen reader, and preview content a11y + ## 0.20.1 ### Patch Changes diff --git a/src/components/onboarding-modal/OnboardingModal.tsx b/src/components/onboarding-modal/OnboardingModal.tsx index abd4ca3f..d0f0f2ea 100644 --- a/src/components/onboarding-modal/OnboardingModal.tsx +++ b/src/components/onboarding-modal/OnboardingModal.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useCallback } from "react"; +import { useState, useRef, useEffect, useCallback, useId } from "react"; import { Modal, ModalSize, @@ -78,14 +78,36 @@ export const OnboardingModal = ({ }: OnboardingModalProps) => { const { t } = useCunningham(); const { isMobile } = useResponsive(); + + // currentStep = selected tab (controls panel content, aria-selected, active style). + // focusedStep = roving tabindex target (controls which tab has tabindex=0). + // Arrows move focusedStep only; Enter/Space/click/Next/Prev sync both. const [currentStep, setCurrentStep] = useState(initialStep); + const [focusedStep, setFocusedStep] = useState(initialStep); const [displayedStep, setDisplayedStep] = useState(initialStep); const [isFading, setIsFading] = useState(false); + // SR: polite announcement when Next/Previous keeps focus on the button (not tabs). + const [stepAnnouncement, setStepAnnouncement] = useState(""); const stepRefs = useRef<(HTMLButtonElement | null)[]>([]); - const stepsContainerRef = useRef(null); const timeoutRef = useRef | null>(null); + // Unique tab/panel ids per modal instance. + const reactId = useId(); + const baseId = `onboarding-${reactId.replace(/:/g, "")}`; + const getTabId = useCallback( + (index: number) => `${baseId}-tab-${index + 1}`, + [baseId], + ); + const getPanelId = useCallback( + (index: number) => `${baseId}-panel-${index + 1}`, + [baseId], + ); + const getPanelDescId = useCallback( + (index: number) => `${baseId}-panel-desc-${index + 1}`, + [baseId], + ); + const isLastStep = currentStep === steps.length - 1; const isFirstStep = currentStep === 0; @@ -100,31 +122,28 @@ export const OnboardingModal = ({ const showContentZone = !hideContent && !!activeStep?.content; /** - * Generates accessible label for a step button. + * Accessible label for a step button. Selected state is conveyed by + * aria-selected on the - ); -}); + return ( + + ); + }, +); OnboardingStepItem.displayName = "OnboardingStepItem"; diff --git a/src/components/onboarding-modal/index.scss b/src/components/onboarding-modal/index.scss index 28e3f2f2..e604da1d 100644 --- a/src/components/onboarding-modal/index.scss +++ b/src/components/onboarding-modal/index.scss @@ -13,6 +13,19 @@ $transition-duration: 200ms; gap: 1.5rem; } +// SR-only live region (Next/Prev step announcements). +.c__onboarding-modal__sr-status { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .c__onboarding-modal__header { text-align: center; padding: 0 var(--c--globals--spacings--base); diff --git a/src/components/onboarding-modal/onboarding-modal.stories.tsx b/src/components/onboarding-modal/onboarding-modal.stories.tsx index 7fe67360..af240a98 100644 --- a/src/components/onboarding-modal/onboarding-modal.stories.tsx +++ b/src/components/onboarding-modal/onboarding-modal.stories.tsx @@ -50,6 +50,7 @@ import { OnboardingStep } from "./types"; * | `title` | `string` | Step title | * | `description` | `string` | Step description, visible only when the step is active | * | `content` | `ReactNode` | Content displayed in the preview zone (image, video, component, etc.) | + * | `contentAlt` | `string` | Accessible description of the content zone (omit if decorative) | * * ## OnboardingModalProps * diff --git a/src/components/onboarding-modal/types.ts b/src/components/onboarding-modal/types.ts index a47ee1ed..c34e03aa 100644 --- a/src/components/onboarding-modal/types.ts +++ b/src/components/onboarding-modal/types.ts @@ -11,4 +11,6 @@ export interface OnboardingStep { description?: ReactNode; /** Content displayed in the preview zone (image, video, component, etc.) */ content?: ReactNode; + /** SR description for the preview zone when it is not decorative (GIF, video, custom content). */ + contentAlt?: string; }