From 97e9c3258e61ad4b6702a9ea0c9fd8b89c67a32c Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Wed, 20 May 2026 09:42:42 +0200 Subject: [PATCH] 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. --- .../components/layout/header/tour-target.tsx | 11 + .../onboarding/skip-hold-button.tsx | 64 ------ .../components/onboarding/tour-fork-step.tsx | 41 ---- .../src/components/onboarding/tour-shell.tsx | 190 ++++++++++++++++++ .../onboarding/tour-step-content.tsx | 33 ++- 5 files changed, 223 insertions(+), 116 deletions(-) create mode 100644 apps/nextjs/src/components/layout/header/tour-target.tsx delete mode 100644 apps/nextjs/src/components/onboarding/skip-hold-button.tsx delete mode 100644 apps/nextjs/src/components/onboarding/tour-fork-step.tsx create mode 100644 apps/nextjs/src/components/onboarding/tour-shell.tsx diff --git a/apps/nextjs/src/components/layout/header/tour-target.tsx b/apps/nextjs/src/components/layout/header/tour-target.tsx new file mode 100644 index 000000000..b23572d1e --- /dev/null +++ b/apps/nextjs/src/components/layout/header/tour-target.tsx @@ -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 }) => ( + + {children} + +); diff --git a/apps/nextjs/src/components/onboarding/skip-hold-button.tsx b/apps/nextjs/src/components/onboarding/skip-hold-button.tsx deleted file mode 100644 index f512744b5..000000000 --- a/apps/nextjs/src/components/onboarding/skip-hold-button.tsx +++ /dev/null @@ -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 | 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 ( - - - {progress > 0 && } - - ); -}; diff --git a/apps/nextjs/src/components/onboarding/tour-fork-step.tsx b/apps/nextjs/src/components/onboarding/tour-fork-step.tsx deleted file mode 100644 index 4a1ce4ffe..000000000 --- a/apps/nextjs/src/components/onboarding/tour-fork-step.tsx +++ /dev/null @@ -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 ( - - {description && {description}} - - {question} - - - - - - - - {t("onboardingTour.learnMore")} - - - ); -}; diff --git a/apps/nextjs/src/components/onboarding/tour-shell.tsx b/apps/nextjs/src/components/onboarding/tour-shell.tsx new file mode 100644 index 000000000..702c28634 --- /dev/null +++ b/apps/nextjs/src/components/onboarding/tour-shell.tsx @@ -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; + position?: FloatingPosition | Record; +} + +type TourController = Parameters["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 ( + + ); +}; + +interface TourDoneButtonProps { + label: string; + onClick: () => void; +} + +const TourDoneButton = ({ label, onClick }: TourDoneButtonProps) => { + return ( + + ); +}; + +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 ( + ( + <> + + + {(controller.currentStepIndex ?? 0) + 1} + + + / + + + {controller.tour.length} + + + {controller.currentStepIndex === 0 && ( +
+ Homarr +
+ )} + + )} + nextStepNavigation={(controller) => { + const nextStep = controller.tour[controller.currentStepIndex + 1]; + const action = bindForwardAction(controller, () => { + navigateAndAdvance(controller.currentStep?.id, nextStep?.id, () => controller.nextStep()); + }); + return ; + }} + endStepNavigation={(controller) => { + const action = bindForwardAction(controller, () => controller.endTour()); + return ; + }} + prevStepNavigation={(controller) => ( + + )} + skipNavigation={(controller) => ( + + )} + 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} +
+ ); +}; diff --git a/apps/nextjs/src/components/onboarding/tour-step-content.tsx b/apps/nextjs/src/components/onboarding/tour-step-content.tsx index 8c8248594..05b7b4bd3 100644 --- a/apps/nextjs/src/components/onboarding/tour-step-content.tsx +++ b/apps/nextjs/src/components/onboarding/tour-step-content.tsx @@ -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 ( - - {description} - - - {t("onboardingTour.learnMore")} + + + {icon && ( + + {icon} + + )} + + {description} + + + + + + + {t("onboardingTour.learnMore")} + );