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:
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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user