feat(onboarding): add shared tour shell with keyboard navigation

Introduce TourShell with step counter, Enter key support, route-aware
advancement, and TourTarget wrappers. Remove unused fork and hold-skip steps.
This commit is contained in:
Thomas Camlong committed 2026-05-20 09:42:42 +02:00
1 parent 8e6f31e92a
commit 97e9c3258e
5 files changed
+223 -116

No files matched your search

@@ -0,0 +1,11 @@
"use client";
import type { ReactNode } from "react";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { Box } from "@mantine/core";
export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => (
<OnboardingTour.Target id={id}>
<Box data-tour-target={id}>{children}</Box>
</OnboardingTour.Target>
);
@@ -1,64 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Progress, Stack, Text } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
const HOLD_DURATION_MS = 2000;
const TICK_INTERVAL_MS = 50;
interface SkipHoldButtonProps {
onSkip: () => void;
}
export const SkipHoldButton = ({ onSkip }: SkipHoldButtonProps) => {
const t = useI18n();
const [progress, setProgress] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const clearHoldInterval = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
useEffect(() => clearHoldInterval, [clearHoldInterval]);
const handlePointerDown = useCallback(() => {
clearHoldInterval();
setProgress(0);
let elapsed = 0;
intervalRef.current = setInterval(() => {
elapsed += TICK_INTERVAL_MS;
const pct = Math.min((elapsed / HOLD_DURATION_MS) * 100, 100);
setProgress(pct);
if (elapsed >= HOLD_DURATION_MS) {
clearHoldInterval();
onSkip();
}
}, TICK_INTERVAL_MS);
}, [onSkip, clearHoldInterval]);
const handlePointerUp = useCallback(() => {
clearHoldInterval();
setProgress(0);
}, [clearHoldInterval]);
return (
<Stack gap={4} mt="xs">
<Button
size="xs"
variant="subtle"
color="dimmed"
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
>
<Text size="xs">{t("onboardingTour.skipHold")}</Text>
</Button>
{progress > 0 && <Progress value={progress} size="xs" color="red" animated />}
</Stack>
);
};
@@ -1,41 +0,0 @@
"use client";
import { Anchor, Button, Group, Stack, Text } from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import type { HomarrDocumentationPath } from "@homarr/definitions";
import { createDocumentationLink } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
interface TourForkStepProps {
question: string;
description?: string;
docPath: HomarrDocumentationPath;
onYes: () => void;
onNo: () => void;
}
export const TourForkStep = ({ question, description, docPath, onYes, onNo }: TourForkStepProps) => {
const t = useI18n();
return (
<Stack gap="sm">
{description && <Text size="sm">{description}</Text>}
<Text size="sm" fw={500}>
{question}
</Text>
<Group gap="sm">
<Button size="xs" onClick={onYes}>
{t("onboardingTour.forkYes")}
</Button>
<Button size="xs" variant="light" onClick={onNo}>
{t("onboardingTour.forkNo")}
</Button>
</Group>
<Anchor href={createDocumentationLink(docPath)} target="_blank" size="xs" c="dimmed">
<IconExternalLink size={14} style={{ verticalAlign: "middle", marginRight: 4 }} />
{t("onboardingTour.learnMore")}
</Anchor>
</Stack>
);
};
@@ -0,0 +1,190 @@
"use client";
import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import type { OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { Button, Center, Group, Image, Kbd, Text } from "@mantine/core";
import type { FloatingPosition } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
interface TourShellProps extends PropsWithChildren {
steps: OnboardingTourStep[];
started: boolean;
onEnd: () => void;
stepRoutes?: Record<string, string>;
position?: FloatingPosition | Record<string, FloatingPosition>;
}
type TourController = Parameters<NonNullable<React.ComponentProps<typeof OnboardingTour>["nextStepNavigation"]>>[0];
const pollForElement = (targetId: string, callback: () => void) => {
const interval = setInterval(() => {
const targetElement = document.querySelector(`[data-tour-target="${targetId}"]`);
if (targetElement) {
clearInterval(interval);
callback();
}
}, 50);
const timeout = setTimeout(() => clearInterval(interval), 5000);
return () => {
clearInterval(interval);
clearTimeout(timeout);
};
};
const isTypingTarget = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable;
};
interface TourForwardButtonProps {
label: string;
onClick: () => void;
}
const TourForwardButton = ({ label, onClick }: TourForwardButtonProps) => {
return (
<Button size="sm" variant="light" onClick={onClick} rightSection={<Kbd size="xs">↵</Kbd>}>
{label}
</Button>
);
};
interface TourDoneButtonProps {
label: string;
onClick: () => void;
}
const TourDoneButton = ({ label, onClick }: TourDoneButtonProps) => {
return (
<Button size="sm" onClick={onClick} rightSection={<Kbd size="xs">↵</Kbd>}>
{label}
</Button>
);
};
export const TourShell = ({ steps, started, onEnd, stepRoutes, position, children }: TourShellProps) => {
const t = useI18n();
const router = useRouter();
const forwardActionRef = useRef<(() => void) | null>(null);
const navigateAndAdvance = useCallback(
(currentStepId: string | undefined, targetStepId: string | undefined, advance: () => void) => {
if (!stepRoutes || !targetStepId) {
advance();
return;
}
const currentRoute = currentStepId ? stepRoutes[currentStepId] : undefined;
const targetRoute = stepRoutes[targetStepId];
if (targetRoute && targetRoute !== currentRoute) {
router.push(targetRoute);
pollForElement(targetStepId, advance);
} else {
advance();
}
},
[router, stepRoutes],
);
const bindForwardAction = useCallback((controller: TourController, action: () => void) => {
forwardActionRef.current = action;
return action;
}, []);
useEffect(() => {
if (!started) {
forwardActionRef.current = null;
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
if (isTypingTarget(event.target)) return;
event.preventDefault();
forwardActionRef.current?.();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [started]);
return (
<OnboardingTour
tour={steps}
started={started}
onOnboardingTourEnd={onEnd}
withStepper={false}
header={(controller) => (
<>
<Group justify="center" gap={4} py={4}>
<Text size="sm" fw={600}>
{(controller.currentStepIndex ?? 0) + 1}
</Text>
<Text size="sm" c="dimmed">
/
</Text>
<Text size="sm" c="dimmed">
{controller.tour.length}
</Text>
</Group>
{controller.currentStepIndex === 0 && (
<Center py="xs">
<Image src={homarrLogoPath} alt="Homarr" w={64} h={64} fit="contain" />
</Center>
)}
</>
)}
nextStepNavigation={(controller) => {
const nextStep = controller.tour[controller.currentStepIndex + 1];
const action = bindForwardAction(controller, () => {
navigateAndAdvance(controller.currentStep?.id, nextStep?.id, () => controller.nextStep());
});
return <TourForwardButton label={t("onboardingTour.next")} onClick={action} />;
}}
endStepNavigation={(controller) => {
const action = bindForwardAction(controller, () => controller.endTour());
return <TourDoneButton label={t("onboardingTour.done")} onClick={action} />;
}}
prevStepNavigation={(controller) => (
<Button
size="sm"
variant="default"
onClick={() => {
const prevStep = controller.tour[controller.currentStepIndex - 1];
navigateAndAdvance(controller.currentStep?.id, prevStep?.id, () => controller.prevStep());
}}
>
{t("onboardingTour.prev")}
</Button>
)}
skipNavigation={(controller) => (
<Button size="sm" variant="subtle" color="gray" onClick={() => controller.skipTour()}>
{t("onboardingTour.skip")}
</Button>
)}
withPrevButton
focusRevealProps={{
disableTargetInteraction: true,
popoverProps: {
position: position ?? { base: "bottom", sm: "right" },
width: 420,
shadow: "xl",
radius: "lg",
middlewares: { shift: { padding: 16 }, flip: true },
},
}}
cutoutPadding={12}
cutoutRadius={12}
>
{children}
</OnboardingTour>
);
};
@@ -1,27 +1,38 @@
"use client";
import { Anchor, Stack, Text } from "@mantine/core";
import type { ReactNode } from "react";
import { Anchor, Divider, Group, Stack, Text, ThemeIcon } from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import type { HomarrDocumentationPath } from "@homarr/definitions";
import { createDocumentationLink } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
interface TourStepContentProps {
description: string;
docPath: HomarrDocumentationPath;
docHash?: `#${string}`;
documentationHref: string;
icon?: ReactNode;
}
export const TourStepContent = ({ description, docPath, docHash }: TourStepContentProps) => {
export const TourStepContent = ({ description, documentationHref, icon }: TourStepContentProps) => {
const t = useI18n();
return (
<Stack gap="xs">
<Text size="sm">{description}</Text>
<Anchor href={createDocumentationLink(docPath, docHash)} target="_blank" size="xs" c="dimmed">
<IconExternalLink size={14} style={{ verticalAlign: "middle", marginRight: 4 }} />
{t("onboardingTour.learnMore")}
<Stack gap="sm">
<Group gap="sm" wrap="nowrap" align="flex-start">
{icon && (
<ThemeIcon variant="light" size="lg" radius="md" mt={2}>
{icon}
</ThemeIcon>
)}
<Text size="sm" lh={1.7} style={{ flex: 1 }}>
{description}
</Text>
</Group>
<Divider />
<Anchor href={documentationHref} target="_blank" size="xs" fw={500}>
<Group gap={4} wrap="nowrap">
<IconExternalLink size={14} />
{t("onboardingTour.learnMore")}
</Group>
</Anchor>
</Stack>
);