mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
fix: point the phone tour at the bottom tab bar and skip steps with nothing on screen to show
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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")}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user