feat(a11y): dialog semantics + focus trap for the onboarding tour

- role=dialog/aria-modal/aria-labelledby/aria-describedby on the onboarding tooltip
- trap focus inside the tooltip while the tour runs; Esc skips (lockScroll off so
  scrollIntoView can still bring each step's target into view)
- merge the focus-trap ref with the existing measurement ref via a callback ref
- aria-hidden on the click-to-advance target overlay (redundant with the Next button)
This commit is contained in:
c0mrade
2026-05-25 23:51:26 +03:00
parent f31160208a
commit 5c7d4b4888

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useFocusTrap } from '../hooks/useFocusTrap';
interface OnboardingStep { interface OnboardingStep {
target: string; // data-onboarding attribute value target: string; // data-onboarding attribute value
@@ -126,6 +127,17 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
onSkip(); onSkip();
}; };
// Trap focus inside the tooltip while the tour runs; Esc skips it.
// lockScroll stays off so scrollIntoView can bring each target into view.
const trapRef = useFocusTrap<HTMLDivElement>(true, { onEscape: handleSkip, lockScroll: false });
const setTooltipNode = useCallback(
(node: HTMLDivElement | null) => {
tooltipRef.current = node;
trapRef.current = node;
},
[trapRef],
);
// Calculate tooltip position // Calculate tooltip position
const getTooltipStyle = (): React.CSSProperties => { const getTooltipStyle = (): React.CSSProperties => {
if (!targetRect) return { opacity: 0 }; if (!targetRect) return { opacity: 0 };
@@ -205,7 +217,11 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
{/* Tooltip */} {/* Tooltip */}
<div <div
ref={tooltipRef} ref={setTooltipNode}
role="dialog"
aria-modal="true"
aria-labelledby="onboarding-title"
aria-describedby="onboarding-desc"
className={`onboarding-tooltip tooltip-${step.placement}`} className={`onboarding-tooltip tooltip-${step.placement}`}
style={{ style={{
...getTooltipStyle(), ...getTooltipStyle(),
@@ -229,8 +245,12 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
</div> </div>
{/* Content */} {/* Content */}
<h3 className="mb-2 text-lg font-semibold text-dark-50">{step.title}</h3> <h3 id="onboarding-title" className="mb-2 text-lg font-semibold text-dark-50">
<p className="mb-5 text-sm text-dark-400">{step.description}</p> {step.title}
</h3>
<p id="onboarding-desc" className="mb-5 text-sm text-dark-400">
{step.description}
</p>
{/* Actions */} {/* Actions */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -259,6 +279,7 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
{/* Click handler to advance on target click — only when overlay is fully visible */} {/* Click handler to advance on target click — only when overlay is fully visible */}
{targetRect && isVisible && ( {targetRect && isVisible && (
<div <div
aria-hidden="true"
className="absolute cursor-pointer" className="absolute cursor-pointer"
style={{ style={{
top: targetRect.top, top: targetRect.top,