fix: point the phone tour at the bottom tab bar and skip steps with nothing on screen to show

This commit is contained in:
Linus Rath
2026-10-28 03:19:00 +01:00
parent fbbeac5291
commit 2c4e259ddc
5 changed files with 156 additions and 25 deletions
+1
View File
@@ -371,6 +371,7 @@ export function NavigationRail({
key={item.id}
href={item.href}
onClick={handleNavClick(item.id as 'mail' | 'calendar' | 'contacts' | 'files' | 'settings')}
data-tour={`nav-${item.id}`}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
+90
View File
@@ -0,0 +1,90 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// The tour on a phone, where the rail's anchors are not rendered, and its
// keyboard handling.
const tour = vi.hoisted(() => ({
currentStep: 0,
nextStep: vi.fn(),
prevStep: vi.fn(),
stopTour: vi.fn(),
}));
vi.mock('../tour-provider', () => ({
useTour: () => ({
currentStep: tour.currentStep,
totalSteps: 3,
steps: [
{ id: 'one', target: '[data-tour="one"]', titleKey: 'one', descriptionKey: 'one_desc', placement: 'right' },
{ id: 'two', target: '[data-tour="two"]', titleKey: 'two', descriptionKey: 'two_desc', placement: 'right' },
{ id: 'three', target: '[data-tour="three"]', titleKey: 'three', descriptionKey: 'three_desc', placement: 'right' },
],
nextStep: tour.nextStep,
prevStep: tour.prevStep,
stopTour: tour.stopTour,
}),
}));
const { TourOverlay, findTourTarget } = await import('../tour-overlay');
const { getTourSteps } = await import('../tour-steps');
/** jsdom lays nothing out: give the elements marked `data-size` a box. */
const realRect = HTMLElement.prototype.getBoundingClientRect;
beforeEach(() => {
vi.useFakeTimers();
tour.currentStep = 0;
tour.nextStep.mockClear();
tour.prevStep.mockClear();
tour.stopTour.mockClear();
HTMLElement.prototype.getBoundingClientRect = function () {
const sized = this.hasAttribute('data-size');
return { top: 10, left: 10, width: sized ? 40 : 0, height: sized ? 40 : 0, right: 50, bottom: 50, x: 10, y: 10, toJSON: () => ({}) } as DOMRect;
};
Element.prototype.scrollIntoView = vi.fn();
});
afterEach(() => {
vi.useRealTimers();
HTMLElement.prototype.getBoundingClientRect = realRect;
hosts.splice(0).forEach((h) => h.remove());
document.documentElement.removeAttribute('dir');
});
const hosts: HTMLElement[] = [];
function anchors(html: string) {
const host = document.createElement('div');
hosts.push(host);
host.innerHTML = html;
document.body.appendChild(host);
}
describe('tour targets', () => {
it('points at the copy of an anchor that is on screen', () => {
anchors('<a data-tour="one" id="rail"></a><a data-tour="one" id="tabbar" data-size></a>');
expect(findTourTarget('[data-tour="one"]')?.id).toBe('tabbar');
});
it('skips a step whose target is in the page but not shown, instead of leaving the user behind the overlay', () => {
anchors('<a data-tour="one"></a>');
render(<TourOverlay />);
act(() => { vi.advanceTimersByTime(1100); });
expect(tour.nextStep).toHaveBeenCalledTimes(1);
});
it('shows a step whose target is on screen', () => {
anchors('<a data-tour="one" data-size></a>');
render(<TourOverlay />);
act(() => { vi.advanceTimersByTime(1100); });
expect(tour.nextStep).not.toHaveBeenCalled();
expect(screen.getByRole('dialog', { name: 'one' })).toBeInTheDocument();
});
it('leaves out the shortcuts step on a touch screen and contacts when they are off', () => {
const ids = (o: Partial<Parameters<typeof getTourSteps>[0]>) =>
getTourSteps({ isDemoMode: false, supportsCalendar: true, supportsWebDAV: true, ...o }).map((s) => s.id);
expect(ids({})).toContain('shortcuts');
expect(ids({ touch: true })).not.toContain('shortcuts');
expect(ids({})).toContain('nav-contacts');
expect(ids({ supportsContacts: false })).not.toContain('nav-contacts');
});
});
+43 -24
View File
@@ -18,15 +18,31 @@ const PADDING = 8;
const TOOLTIP_GAP = 12;
const TOOLTIP_MAX_W = 360;
/**
* The first element for the selector that is on screen. The same anchor can
* exist twice (the rail and the phone's tab bar); one with no size is not
* rendered in this layout and has nothing to point at.
*/
export function findTourTarget(selector: string): Element | null {
for (const el of document.querySelectorAll(selector)) {
const r = el.getBoundingClientRect();
if (r.width > 0 || r.height > 0) return el;
}
return null;
}
function getTargetRect(selector: string): Rect | null {
const el = document.querySelector(selector);
const el = findTourTarget(selector);
if (!el) return null;
const r = el.getBoundingClientRect();
// Element might exist but be hidden (zero dimensions)
if (r.width === 0 && r.height === 0) return null;
return { top: r.top, left: r.left, width: r.width, height: r.height };
}
/** Polls (100 ms apart) before a step whose target is in the DOM but has no size is skipped. */
const HIDDEN_TARGET_ATTEMPTS = 10;
/** Polls before a step whose target never appeared (another page loading) is skipped. */
const MISSING_TARGET_ATTEMPTS = 50;
function computeTooltipPosition(
target: Rect,
placement: "top" | "bottom" | "left" | "right",
@@ -90,7 +106,13 @@ export function TourOverlay() {
// Use refs for callbacks to avoid stale closures in timers/intervals
const updatePositionRef = useRef<() => void>(() => {});
const nextStepRef = useRef<() => void>(() => {});
const prevStepRef = useRef<() => void>(() => {});
// A step with nothing to point at is skipped in the direction the user went.
const directionRef = useRef<1 | -1>(1);
const goNext = useCallback(() => { directionRef.current = 1; nextStep(); }, [nextStep]);
const goPrev = useCallback(() => { directionRef.current = -1; prevStep(); }, [prevStep]);
nextStepRef.current = nextStep;
prevStepRef.current = prevStep;
const focusTrapRef = useFocusTrap({
isActive: visible,
@@ -123,7 +145,6 @@ export function TourOverlay() {
// Wait for target element to appear, then show
useEffect(() => {
if (!step) return;
console.log(`[Tour] Step ${currentStep + 1}/${totalSteps}: "${step.id}" - target: ${step.target}, placement: ${step.placement}, interactive: ${!!step.interactive}`);
setVisible(false);
// Keep old targetRect and tooltipPos so the cutout/tooltip animate to the new position
// instead of disappearing and reappearing
@@ -140,20 +161,16 @@ export function TourOverlay() {
}
let attempts = 0;
const maxAttempts = 50; // 5 seconds
let cancelled = false;
const tryFind = () => {
if (cancelled) return true;
const el = document.querySelector(step.target);
const el = findTourTarget(step.target);
if (el) {
const rect = el.getBoundingClientRect();
console.log(`[Tour] Step ${currentStep + 1} "${step.id}": element FOUND (${rect.width}x${rect.height} at ${Math.round(rect.left)},${Math.round(rect.top)})`);
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
// Delay after scroll for layout to settle
pendingTimerRef.current = setTimeout(() => {
if (cancelled) return;
console.log(`[Tour] Step ${currentStep + 1} "${step.id}": showing tooltip`);
updatePositionRef.current();
setVisible(true);
// Second position update after tooltip renders with final dimensions
@@ -163,24 +180,26 @@ export function TourOverlay() {
}, 200);
return true;
}
if (attempts % 10 === 0) {
console.log(`[Tour] Step ${currentStep + 1} "${step.id}": element NOT found (attempt ${attempts + 1}/${maxAttempts})`);
}
return false;
};
if (tryFind()) return () => { cancelled = true; };
// Poll for element appearance (for page navigation)
// Poll for the element to appear (another page loading). One that is in
// the DOM but not rendered in this layout (the rail on a phone) is given
// up on sooner.
const interval = setInterval(() => {
attempts++;
if (tryFind() || attempts >= maxAttempts) {
if (tryFind()) {
clearInterval(interval);
if (attempts >= maxAttempts && !cancelled) {
// Skip this step if element never appears
console.warn(`[Tour] Step ${currentStep + 1} "${step.id}": SKIPPED - element never appeared after ${maxAttempts} attempts`);
nextStepRef.current();
}
return;
}
const limit = document.querySelector(step.target) ? HIDDEN_TARGET_ATTEMPTS : MISSING_TARGET_ATTEMPTS;
if (attempts >= limit) {
clearInterval(interval);
if (cancelled) return;
if (directionRef.current === -1 && currentStep > 0) prevStepRef.current();
else nextStepRef.current();
}
}, 100);
@@ -219,11 +238,11 @@ export function TourOverlay() {
if (e.key === "ArrowRight" || e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
nextStep();
goNext();
} else if (e.key === "ArrowLeft") {
e.preventDefault();
e.stopPropagation();
prevStep();
goPrev();
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
@@ -232,7 +251,7 @@ export function TourOverlay() {
};
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [nextStep, prevStep, stopTour]);
}, [goNext, goPrev, stopTour]);
// Re-position after tooltip content renders with new dimensions
useEffect(() => {
@@ -373,7 +392,7 @@ export function TourOverlay() {
</button>
<div className="flex gap-2">
<button
onClick={prevStep}
onClick={goPrev}
disabled={isFirst}
className={cn(
"text-xs px-3 py-1.5 rounded-md border border-border transition-colors",
@@ -385,7 +404,7 @@ export function TourOverlay() {
{t("tour.back")}
</button>
<button
onClick={nextStep}
onClick={goNext}
className="text-xs px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
{isLast ? t("tour.finish") : t("tour.next")}
+12 -1
View File
@@ -9,6 +9,7 @@ import { useWebDAVStore } from "@/stores/webdav-store";
import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { getTourSteps, type TourStep } from "./tour-steps";
import { useMediaQuery } from "@/hooks/use-media-query";
import { TourOverlay } from "./tour-overlay";
const TOUR_COMPLETED_KEY = "tour_completed";
@@ -50,7 +51,17 @@ export function TourProvider({ children }: { children: ReactNode }) {
const [currentStep, setCurrentStep] = useState(0);
const [hasCompletedTour, setHasCompletedTour] = useState(false);
const steps = getTourSteps({ isDemoMode, supportsCalendar: supportsCalendar && calendarEnabled, supportsWebDAV: supportsWebDAV !== false });
const client = useAuthStore((s) => s.client);
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
const touch = useMediaQuery("(pointer: coarse)");
const steps = getTourSteps({
isDemoMode,
supportsCalendar: supportsCalendar && calendarEnabled,
supportsWebDAV: supportsWebDAV !== false,
supportsContacts: (client?.supportsContacts?.() ?? false) && contactsEnabled,
touch,
});
useEffect(() => {
// One-time migration: if the legacy per-device flag is set but the synced
+10
View File
@@ -201,12 +201,22 @@ export function getTourSteps(options: {
isDemoMode: boolean;
supportsCalendar: boolean;
supportsWebDAV: boolean;
/** Contacts available and allowed; defaults to true. */
supportsContacts?: boolean;
/** A touch screen: no keyboard to use the shortcuts with. */
touch?: boolean;
}): TourStep[] {
let steps = [...BASE_TOUR_STEPS];
if (!options.supportsCalendar) {
steps = steps.filter((s) => s.id !== "nav-calendar");
}
if (options.supportsContacts === false) {
steps = steps.filter((s) => s.id !== "nav-contacts");
}
if (options.touch) {
steps = steps.filter((s) => s.id !== "shortcuts");
}
if (options.isDemoMode) {
const demoSteps = DEMO_TOUR_STEPS.filter((s) => {