Skip to content

Commit 32801d7

Browse files
committed
♿️(onboarding) implement manual vertical tablist and tab semantics on steps
manual activation, roving tabindex, panel ids, next/prev live region.
1 parent 802a535 commit 32801d7

3 files changed

Lines changed: 202 additions & 111 deletions

File tree

src/components/onboarding-modal/OnboardingModal.tsx

Lines changed: 152 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useRef, useEffect, useCallback } from "react";
1+
import { useState, useRef, useEffect, useCallback, useId } from "react";
22
import {
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

Comments
 (0)