mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
- 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)
297 lines
8.5 KiB
TypeScript
297 lines
8.5 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useFocusTrap } from '../hooks/useFocusTrap';
|
|
|
|
interface OnboardingStep {
|
|
target: string; // data-onboarding attribute value
|
|
title: string;
|
|
description: string;
|
|
placement: 'top' | 'bottom' | 'left' | 'right';
|
|
}
|
|
|
|
interface OnboardingProps {
|
|
steps: OnboardingStep[];
|
|
onComplete: () => void;
|
|
onSkip: () => void;
|
|
}
|
|
|
|
const STORAGE_KEY = 'onboarding_completed';
|
|
|
|
// eslint-disable-next-line react-refresh/only-export-components
|
|
export function useOnboarding() {
|
|
const [isCompleted, setIsCompleted] = useState(() => {
|
|
return localStorage.getItem(STORAGE_KEY) === 'true';
|
|
});
|
|
|
|
const complete = useCallback(() => {
|
|
localStorage.setItem(STORAGE_KEY, 'true');
|
|
setIsCompleted(true);
|
|
}, []);
|
|
|
|
const reset = useCallback(() => {
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
setIsCompleted(false);
|
|
}, []);
|
|
|
|
return { isCompleted, complete, reset };
|
|
}
|
|
|
|
export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProps) {
|
|
const { t } = useTranslation();
|
|
const [currentStep, setCurrentStep] = useState(0);
|
|
const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
|
|
|
const onCompleteRef = useRef(onComplete);
|
|
useEffect(() => {
|
|
onCompleteRef.current = onComplete;
|
|
}, [onComplete]);
|
|
|
|
const step = steps[currentStep];
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
let attempts = 0;
|
|
const maxAttempts = 6;
|
|
const isLastStep = currentStep === steps.length - 1;
|
|
|
|
setIsVisible(false);
|
|
setTargetRect(null);
|
|
|
|
const tryFind = () => {
|
|
if (cancelled) return;
|
|
const target = document.querySelector(`[data-onboarding="${step.target}"]`);
|
|
if (target) {
|
|
const rect = target.getBoundingClientRect();
|
|
setTargetRect(rect);
|
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
window.setTimeout(() => {
|
|
if (!cancelled) setIsVisible(true);
|
|
}, 100);
|
|
return;
|
|
}
|
|
attempts += 1;
|
|
if (attempts < maxAttempts) {
|
|
window.setTimeout(tryFind, 200);
|
|
return;
|
|
}
|
|
if (isLastStep) {
|
|
onCompleteRef.current();
|
|
} else {
|
|
setCurrentStep((prev) => Math.min(prev + 1, steps.length - 1));
|
|
}
|
|
};
|
|
|
|
const timer = window.setTimeout(tryFind, 300);
|
|
return () => {
|
|
cancelled = true;
|
|
window.clearTimeout(timer);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [step.target]);
|
|
|
|
// Recalculate position on resize/scroll
|
|
useEffect(() => {
|
|
const updatePosition = () => {
|
|
const target = document.querySelector(`[data-onboarding="${step.target}"]`);
|
|
if (target) {
|
|
setTargetRect(target.getBoundingClientRect());
|
|
}
|
|
};
|
|
|
|
window.addEventListener('resize', updatePosition);
|
|
window.addEventListener('scroll', updatePosition, true);
|
|
return () => {
|
|
window.removeEventListener('resize', updatePosition);
|
|
window.removeEventListener('scroll', updatePosition, true);
|
|
};
|
|
}, [step.target]);
|
|
|
|
const handleNext = () => {
|
|
if (currentStep < steps.length - 1) {
|
|
setCurrentStep(currentStep + 1);
|
|
} else {
|
|
onComplete();
|
|
}
|
|
};
|
|
|
|
const handlePrev = () => {
|
|
if (currentStep > 0) {
|
|
setCurrentStep(currentStep - 1);
|
|
}
|
|
};
|
|
|
|
const handleSkip = () => {
|
|
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
|
|
const getTooltipStyle = (): React.CSSProperties => {
|
|
if (!targetRect) return { opacity: 0 };
|
|
|
|
const padding = 16;
|
|
const tooltipWidth = 320;
|
|
const tooltipHeight = tooltipRef.current?.offsetHeight || 150;
|
|
|
|
let top = 0;
|
|
let left = 0;
|
|
|
|
switch (step.placement) {
|
|
case 'bottom':
|
|
top = targetRect.bottom + padding;
|
|
left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2;
|
|
break;
|
|
case 'top':
|
|
top = targetRect.top - tooltipHeight - padding;
|
|
left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2;
|
|
break;
|
|
case 'left':
|
|
top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2;
|
|
left = targetRect.left - tooltipWidth - padding;
|
|
break;
|
|
case 'right':
|
|
top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2;
|
|
left = targetRect.right + padding;
|
|
break;
|
|
}
|
|
|
|
// Keep within viewport
|
|
const viewportWidth = window.innerWidth;
|
|
const viewportHeight = window.innerHeight;
|
|
|
|
if (left < padding) left = padding;
|
|
if (left + tooltipWidth > viewportWidth - padding) {
|
|
left = viewportWidth - tooltipWidth - padding;
|
|
}
|
|
if (top < padding) top = padding;
|
|
if (top + tooltipHeight > viewportHeight - padding) {
|
|
top = viewportHeight - tooltipHeight - padding;
|
|
}
|
|
|
|
return {
|
|
top,
|
|
left,
|
|
width: tooltipWidth,
|
|
opacity: isVisible ? 1 : 0,
|
|
transform: isVisible ? 'scale(1)' : 'scale(0.95)',
|
|
};
|
|
};
|
|
|
|
// Spotlight style
|
|
const getSpotlightStyle = (): React.CSSProperties => {
|
|
if (!targetRect) return { opacity: 0 };
|
|
|
|
const padding = 8;
|
|
return {
|
|
top: targetRect.top - padding,
|
|
left: targetRect.left - padding,
|
|
width: targetRect.width + padding * 2,
|
|
height: targetRect.height + padding * 2,
|
|
opacity: isVisible ? 1 : 0,
|
|
};
|
|
};
|
|
|
|
return createPortal(
|
|
<div className="onboarding-overlay" style={{ opacity: isVisible ? 1 : 0 }}>
|
|
{/* Spotlight */}
|
|
<div
|
|
className="onboarding-spotlight"
|
|
style={{
|
|
...getSpotlightStyle(),
|
|
pointerEvents: isVisible ? 'auto' : 'none',
|
|
}}
|
|
/>
|
|
|
|
{/* Tooltip */}
|
|
<div
|
|
ref={setTooltipNode}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="onboarding-title"
|
|
aria-describedby="onboarding-desc"
|
|
className={`onboarding-tooltip tooltip-${step.placement}`}
|
|
style={{
|
|
...getTooltipStyle(),
|
|
pointerEvents: isVisible ? 'auto' : 'none',
|
|
}}
|
|
>
|
|
{/* Progress indicator */}
|
|
<div className="mb-4 flex items-center gap-1.5">
|
|
{steps.map((s, index) => (
|
|
<div
|
|
key={s.target}
|
|
className={`h-1 rounded-full transition-all duration-300 ${
|
|
index === currentStep
|
|
? 'w-6 bg-accent-500'
|
|
: index < currentStep
|
|
? 'w-2 bg-accent-500/50'
|
|
: 'w-2 bg-dark-700'
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<h3 id="onboarding-title" className="mb-2 text-lg font-semibold text-dark-50">
|
|
{step.title}
|
|
</h3>
|
|
<p id="onboarding-desc" className="mb-5 text-sm text-dark-400">
|
|
{step.description}
|
|
</p>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center justify-between">
|
|
<button
|
|
onClick={handleSkip}
|
|
className="text-sm text-dark-500 transition-colors hover:text-dark-300"
|
|
>
|
|
{t('onboarding.skip', 'Skip')}
|
|
</button>
|
|
|
|
<div className="flex gap-2">
|
|
{currentStep > 0 && (
|
|
<button onClick={handlePrev} className="btn-ghost px-3 py-1.5 text-sm">
|
|
{t('common.back', 'Back')}
|
|
</button>
|
|
)}
|
|
<button onClick={handleNext} className="btn-primary px-4 py-1.5 text-sm">
|
|
{currentStep === steps.length - 1
|
|
? t('onboarding.finish', 'Finish')
|
|
: t('common.next', 'Next')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Click handler to advance on target click — only when overlay is fully visible */}
|
|
{targetRect && isVisible && (
|
|
<div
|
|
aria-hidden="true"
|
|
className="absolute cursor-pointer"
|
|
style={{
|
|
top: targetRect.top,
|
|
left: targetRect.left,
|
|
width: targetRect.width,
|
|
height: targetRect.height,
|
|
}}
|
|
onClick={handleNext}
|
|
/>
|
|
)}
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|