1- import { useState , useRef , useEffect , useCallback } from "react" ;
1+ import { useState , useRef , useEffect , useCallback , useId } from "react" ;
22import {
33 Modal ,
44 ModalSize ,
@@ -78,14 +78,36 @@ export const OnboardingModal = ({
7878} : OnboardingModalProps ) => {
7979 const { t } = useCunningham ( ) ;
8080 const { isMobile } = useResponsive ( ) ;
81+
82+ // currentStep = selected tab (controls panel content, aria-selected, active style).
83+ // focusedStep = roving tabindex target (controls which tab has tabindex=0).
84+ // Arrows move focusedStep only; Enter/Space/click/Next/Prev sync both.
8185 const [ currentStep , setCurrentStep ] = useState ( initialStep ) ;
86+ const [ focusedStep , setFocusedStep ] = useState ( initialStep ) ;
8287 const [ displayedStep , setDisplayedStep ] = useState ( initialStep ) ;
8388 const [ isFading , setIsFading ] = useState ( false ) ;
89+ // SR: polite announcement when Next/Previous keeps focus on the button (not tabs).
90+ const [ stepAnnouncement , setStepAnnouncement ] = useState ( "" ) ;
8491
8592 const stepRefs = useRef < ( HTMLButtonElement | null ) [ ] > ( [ ] ) ;
86- const stepsContainerRef = useRef < HTMLDivElement | null > ( null ) ;
8793 const timeoutRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
8894
95+ // Unique tab/panel ids per modal instance.
96+ const reactId = useId ( ) ;
97+ const baseId = `onboarding-${ reactId . replace ( / : / g, "" ) } ` ;
98+ const getTabId = useCallback (
99+ ( index : number ) => `${ baseId } -tab-${ index + 1 } ` ,
100+ [ baseId ] ,
101+ ) ;
102+ const getPanelId = useCallback (
103+ ( index : number ) => `${ baseId } -panel-${ index + 1 } ` ,
104+ [ baseId ] ,
105+ ) ;
106+ const getPanelDescId = useCallback (
107+ ( index : number ) => `${ baseId } -panel-desc-${ index + 1 } ` ,
108+ [ baseId ] ,
109+ ) ;
110+
89111 const isLastStep = currentStep === steps . length - 1 ;
90112 const isFirstStep = currentStep === 0 ;
91113
@@ -100,31 +122,28 @@ export const OnboardingModal = ({
100122 const showContentZone = ! hideContent && ! ! activeStep ?. content ;
101123
102124 /**
103- * Generates accessible label for a step button.
125+ * Accessible label for a step button. Selected state is conveyed by
126+ * aria-selected on the <button role="tab">; do not duplicate it here.
104127 */
105128 const getStepAriaLabel = useCallback (
106129 ( stepIndex : number , stepTitle : string ) => {
107- const label = t ( "components.onboarding.stepLabel" )
130+ return t ( "components.onboarding.stepLabel" )
108131 . replace ( "{current}" , String ( stepIndex + 1 ) )
109132 . replace ( "{total}" , String ( steps . length ) )
110133 . replace ( "{title}" , stepTitle ) ;
111-
112- if ( stepIndex === currentStep ) {
113- return label + t ( "components.onboarding.currentStepSuffix" ) ;
114- }
115- return label ;
116134 } ,
117- [ t , steps . length , currentStep ] ,
135+ [ t , steps . length ] ,
118136 ) ;
119137
120- /**
121- * Generates accessible label for the content region.
122- */
123- const getContentRegionLabel = useCallback ( ( ) => {
124- return t ( "components.onboarding.contentRegionLabel" )
125- . replace ( "{current}" , String ( displayedStep + 1 ) )
126- . replace ( "{total}" , String ( steps . length ) ) ;
127- } , [ t , displayedStep , steps . length ] ) ;
138+ // Next/Prev: reuses the same label for the live-region announcement.
139+ const buildStepAnnouncement = useCallback (
140+ ( stepIndex : number ) => {
141+ const step = steps [ stepIndex ] ;
142+ if ( ! step ) return "" ;
143+ return getStepAriaLabel ( stepIndex , step . title ) ;
144+ } ,
145+ [ steps , getStepAriaLabel ] ,
146+ ) ;
128147
129148 // Cleanup timeout on unmount
130149 useEffect ( ( ) => {
@@ -135,42 +154,23 @@ export const OnboardingModal = ({
135154 } ;
136155 } , [ ] ) ;
137156
138- // Scroll active step into view
139- useEffect ( ( ) => {
140- const activeRef = stepRefs . current [ currentStep ] ;
141- if ( activeRef ) {
142- activeRef . scrollIntoView ( { behavior : "smooth" , block : "nearest" } ) ;
143- }
144- } , [ currentStep ] ) ;
145-
146157 // Reset step and manage focus when modal opens
147158 useEffect ( ( ) => {
148159 if ( isOpen ) {
149- // Clear any pending animation timeout
150160 if ( timeoutRef . current ) {
151161 clearTimeout ( timeoutRef . current ) ;
152162 timeoutRef . current = null ;
153163 }
154164
155165 setCurrentStep ( initialStep ) ;
166+ setFocusedStep ( initialStep ) ;
156167 setDisplayedStep ( initialStep ) ;
157168 setIsFading ( false ) ;
158-
159- // Focus the initial step button after modal renders
160- // Use requestAnimationFrame + setTimeout to ensure DOM is ready
161- const focusTimeout = setTimeout ( ( ) => {
162- requestAnimationFrame ( ( ) => {
163- const stepButton = stepRefs . current [ initialStep ] ;
164- if ( stepButton && ! isMobile ) {
165- stepButton . focus ( ) ;
166- }
167- } ) ;
168- } , 150 ) ;
169-
170- return ( ) => clearTimeout ( focusTimeout ) ;
169+ setStepAnnouncement ( "" ) ;
171170 }
172- } , [ isOpen , initialStep , isMobile ] ) ;
171+ } , [ isOpen , initialStep ] ) ;
173172
173+ // Activates a step: updates selection + panel content (with fade animation).
174174 const handleStepChange = useCallback (
175175 ( newStep : number ) => {
176176 if ( newStep === currentStep ) return ;
@@ -194,49 +194,97 @@ export const OnboardingModal = ({
194194 [ currentStep ] ,
195195 ) ;
196196
197- // Keyboard navigation for step list (ArrowUp/ArrowDown)
198- useEffect ( ( ) => {
199- const container = stepsContainerRef . current ;
200- if ( ! container ) return ;
201-
202- const handleKeyDown = ( e : KeyboardEvent ) => {
203- if ( e . key !== "ArrowUp" && e . key !== "ArrowDown" ) return ;
204-
205- const target = e . target as HTMLElement ;
206- if ( ! target . closest ( ".c__onboarding-modal__step" ) ) return ;
197+ // Manual-activation vertical tablist: arrows move focus only, Enter/Space activates.
198+ // Avoids duplicate SR announcements on arrow keys because aria-selected stays put until activation.
199+ const handleTablistKeyDown = useCallback (
200+ ( e : React . KeyboardEvent < HTMLDivElement > ) => {
201+ let newFocusedIndex : number | null = null ;
202+
203+ switch ( e . key ) {
204+ case "ArrowDown" :
205+ newFocusedIndex = ( focusedStep + 1 ) % steps . length ;
206+ break ;
207+ case "ArrowUp" :
208+ newFocusedIndex =
209+ ( focusedStep - 1 + steps . length ) % steps . length ;
210+ break ;
211+ case "Home" :
212+ newFocusedIndex = 0 ;
213+ break ;
214+ case "End" :
215+ newFocusedIndex = steps . length - 1 ;
216+ break ;
217+ case "Enter" :
218+ case " " :
219+ e . preventDefault ( ) ;
220+ handleStepChange ( focusedStep ) ;
221+ // Clear stale live-region text when user activates from inside tablist
222+ setStepAnnouncement ( "" ) ;
223+ return ;
224+ default :
225+ return ;
226+ }
207227
208228 e . preventDefault ( ) ;
229+ // Clear stale live-region text from a previous Next/Prev action
230+ setStepAnnouncement ( "" ) ;
231+ setFocusedStep ( newFocusedIndex ) ;
232+
233+ requestAnimationFrame ( ( ) => {
234+ const el = stepRefs . current [ newFocusedIndex ! ] ;
235+ el ?. focus ( ) ;
236+ el ?. scrollIntoView ( { block : "nearest" , inline : "nearest" } ) ;
237+ } ) ;
238+ } ,
239+ [ focusedStep , steps . length , handleStepChange ] ,
240+ ) ;
209241
210- const newIndex =
211- e . key === "ArrowDown"
212- ? ( currentStep + 1 ) % steps . length
213- : ( currentStep - 1 + steps . length ) % steps . length ;
214-
215- handleStepChange ( newIndex ) ;
216-
217- // Focus the new step after state update
218- setTimeout ( ( ) => {
219- stepRefs . current [ newIndex ] ?. focus ( ) ;
220- } , 0 ) ;
221- } ;
242+ // When focus leaves the tablist, reset roving focus to the selected tab
243+ // so that re-entering (Shift+Tab) lands on the active tab (APG spec).
244+ const handleTablistBlur = useCallback (
245+ ( e : React . FocusEvent < HTMLDivElement > ) => {
246+ if ( ! e . currentTarget . contains ( e . relatedTarget as Node ) ) {
247+ setFocusedStep ( currentStep ) ;
248+ }
249+ } ,
250+ [ currentStep ] ,
251+ ) ;
222252
223- container . addEventListener ( "keydown" , handleKeyDown ) ;
224- return ( ) => container . removeEventListener ( "keydown" , handleKeyDown ) ;
225- } , [ currentStep , steps . length , handleStepChange ] ) ;
253+ // Click on a tab: select it and sync roving focus.
254+ const handleTabClick = useCallback (
255+ ( index : number ) => {
256+ setFocusedStep ( index ) ;
257+ handleStepChange ( index ) ;
258+ setStepAnnouncement ( "" ) ;
259+ } ,
260+ [ handleStepChange ] ,
261+ ) ;
226262
227263 const handleNext = useCallback ( ( ) => {
228264 if ( isLastStep ) {
229265 onComplete ( ) ;
230266 } else {
231- handleStepChange ( currentStep + 1 ) ;
267+ const nextIndex = currentStep + 1 ;
268+ setFocusedStep ( nextIndex ) ;
269+ handleStepChange ( nextIndex ) ;
270+ setStepAnnouncement ( buildStepAnnouncement ( nextIndex ) ) ;
232271 }
233- } , [ isLastStep , onComplete , handleStepChange , currentStep ] ) ;
272+ } , [
273+ isLastStep ,
274+ onComplete ,
275+ handleStepChange ,
276+ currentStep ,
277+ buildStepAnnouncement ,
278+ ] ) ;
234279
235280 const handlePrevious = useCallback ( ( ) => {
236281 if ( ! isFirstStep ) {
237- handleStepChange ( currentStep - 1 ) ;
282+ const prevIndex = currentStep - 1 ;
283+ setFocusedStep ( prevIndex ) ;
284+ handleStepChange ( prevIndex ) ;
285+ setStepAnnouncement ( buildStepAnnouncement ( prevIndex ) ) ;
238286 }
239- } , [ isFirstStep , handleStepChange , currentStep ] ) ;
287+ } , [ isFirstStep , handleStepChange , currentStep , buildStepAnnouncement ] ) ;
240288
241289 const handleSkip = useCallback ( ( ) => {
242290 onSkip ?.( ) ;
@@ -341,12 +389,15 @@ export const OnboardingModal = ({
341389 } ) }
342390 >
343391 { /* Desktop: Steps list with keyboard navigation */ }
392+ { /* tablist tabIndex=-1: avoids Chromium Tab stop on overflow:auto */ }
344393 < div
345- ref = { stepsContainerRef }
346394 className = "c__onboarding-modal__steps"
347395 role = "tablist"
348396 aria-orientation = "vertical"
349397 aria-label = { mainTitle }
398+ tabIndex = { - 1 }
399+ onKeyDown = { handleTablistKeyDown }
400+ onBlur = { handleTablistBlur }
350401 >
351402 { steps . map ( ( step , index ) => (
352403 < OnboardingStepItem
@@ -356,22 +407,29 @@ export const OnboardingModal = ({
356407 } }
357408 step = { step }
358409 index = { index }
359- totalSteps = { steps . length }
360410 isActive = { index === currentStep }
411+ isFocused = { index === focusedStep }
361412 ariaLabel = { getStepAriaLabel ( index , step . title ) }
362- onClick = { ( ) => handleStepChange ( index ) }
413+ id = { getTabId ( index ) }
414+ controls = { getPanelId ( index ) }
415+ onClick = { ( ) => handleTabClick ( index ) }
363416 />
364417 ) ) }
365418 </ div >
366419
367- { /* Content zone with ARIA live region */ }
420+ { /* Content zone (tabpanel) */ }
368421 { showContentZone && (
369422 < div
370423 className = "c__onboarding-modal__content"
371424 role = "tabpanel"
372- aria-live = "polite"
373- aria-atomic = "true"
374- aria-label = { getContentRegionLabel ( ) }
425+ id = { getPanelId ( currentStep ) }
426+ aria-labelledby = { getTabId ( currentStep ) }
427+ aria-describedby = {
428+ activeStep ?. contentAlt
429+ ? getPanelDescId ( currentStep )
430+ : undefined
431+ }
432+ tabIndex = { 0 }
375433 >
376434 < div
377435 className = { clsx ( "c__onboarding-modal__content-inner" , {
@@ -380,6 +438,14 @@ export const OnboardingModal = ({
380438 >
381439 { activeStep ?. content }
382440 </ div >
441+ { activeStep ?. contentAlt && (
442+ < span
443+ id = { getPanelDescId ( currentStep ) }
444+ className = "c__onboarding-modal__sr-status"
445+ >
446+ { activeStep . contentAlt }
447+ </ span >
448+ ) }
383449 </ div >
384450 ) }
385451
@@ -405,6 +471,15 @@ export const OnboardingModal = ({
405471 </ div >
406472 ) }
407473 </ div >
474+
475+ { /* SR: step change on Next/Prev (not on tab focus). */ }
476+ < div
477+ className = "c__onboarding-modal__sr-status"
478+ role = "status"
479+ aria-live = "polite"
480+ >
481+ { stepAnnouncement }
482+ </ div >
408483 </ div >
409484 </ Modal >
410485 ) ;
0 commit comments