Add js web stuff to landing page + documentation
This commit is contained in:
685
go/jsruntime/uikit/Tutorial.tsx
Normal file
685
go/jsruntime/uikit/Tutorial.tsx
Normal file
@@ -0,0 +1,685 @@
|
||||
import { createContext, useContext, createSignal, createEffect, onCleanup, Show, For, JSXElement } from "solid-js";
|
||||
import { Icon } from "./Icons.tsx";
|
||||
import { ButtonUI, BUTTON_COLOR_WHITE, BUTTON_COLOR_BLUE } from "./Buttons.tsx";
|
||||
|
||||
export interface TutorialStep {
|
||||
title?: string;
|
||||
content: JSXElement;
|
||||
target?: string | (() => HTMLElement | null) | null;
|
||||
placement?: string;
|
||||
offset?: number;
|
||||
onEnter?: () => void;
|
||||
onLeave?: () => void;
|
||||
}
|
||||
|
||||
interface TutorialProviderProps {
|
||||
steps: TutorialStep[];
|
||||
spotlightPadding?: number;
|
||||
onEnd?: () => void;
|
||||
children?: JSXElement;
|
||||
}
|
||||
|
||||
interface TutorialContextValue {
|
||||
isActive: boolean;
|
||||
currentStepIndex: number;
|
||||
totalSteps: number;
|
||||
currentStep: TutorialStep | null;
|
||||
start: (stepIndex?: number) => void;
|
||||
end: () => void;
|
||||
next: () => void;
|
||||
previous: () => void;
|
||||
goTo: (stepIndex: number) => void;
|
||||
}
|
||||
|
||||
interface TutorialInternalContextValue {
|
||||
isActive: () => boolean;
|
||||
currentStepIndex: () => number;
|
||||
totalSteps: () => number;
|
||||
currentStep: () => TutorialStep | null;
|
||||
targetRect: () => DOMRect | null;
|
||||
start: (stepIndex?: number) => void;
|
||||
end: () => void;
|
||||
next: () => void;
|
||||
previous: () => void;
|
||||
goTo: (stepIndex: number) => void;
|
||||
}
|
||||
|
||||
const TutorialContext = createContext<TutorialContextValue | null>(null);
|
||||
const TutorialInternalContext = createContext<TutorialInternalContextValue | null>(null);
|
||||
|
||||
const _TUTORIAL_ANIMATION_DURATION = 100;
|
||||
|
||||
export function useTutorial(): TutorialContextValue {
|
||||
const context = useContext(TutorialContext);
|
||||
if (!context) {
|
||||
throw new Error("useTutorial must be used within a TutorialProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function useTutorialInternal(): TutorialInternalContextValue {
|
||||
return useContext(TutorialInternalContext)!;
|
||||
}
|
||||
|
||||
interface PopoverPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
placement: string;
|
||||
}
|
||||
|
||||
function calculatePopoverPosition(targetRect: DOMRect, popoverRect: DOMRect, placement: string, offset: number): PopoverPosition {
|
||||
const parts = placement.split("-");
|
||||
const basePlacement = parts[0];
|
||||
const alignment = parts[1] || "center";
|
||||
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
let finalPlacement = placement;
|
||||
const padding = 16;
|
||||
|
||||
switch (basePlacement) {
|
||||
case "top":
|
||||
top = targetRect.top - popoverRect.height - offset;
|
||||
break;
|
||||
case "bottom":
|
||||
top = targetRect.bottom + offset;
|
||||
break;
|
||||
case "left":
|
||||
left = targetRect.left - popoverRect.width - offset;
|
||||
break;
|
||||
case "right":
|
||||
left = targetRect.right + offset;
|
||||
break;
|
||||
}
|
||||
|
||||
if (basePlacement === "top" || basePlacement === "bottom") {
|
||||
switch (alignment) {
|
||||
case "start":
|
||||
left = targetRect.left;
|
||||
break;
|
||||
case "end":
|
||||
left = targetRect.right - popoverRect.width;
|
||||
break;
|
||||
default:
|
||||
left = targetRect.left + (targetRect.width - popoverRect.width) / 2;
|
||||
}
|
||||
} else {
|
||||
switch (alignment) {
|
||||
case "start":
|
||||
top = targetRect.top;
|
||||
break;
|
||||
case "end":
|
||||
top = targetRect.bottom - popoverRect.height;
|
||||
break;
|
||||
default:
|
||||
top = targetRect.top + (targetRect.height - popoverRect.height) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
const viewportHeight = window.innerHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
|
||||
if (basePlacement === "bottom" && top + popoverRect.height > viewportHeight - padding) {
|
||||
const flippedTop = targetRect.top - popoverRect.height - offset;
|
||||
if (flippedTop >= padding) {
|
||||
top = flippedTop;
|
||||
finalPlacement = placement.replace("bottom", "top");
|
||||
}
|
||||
} else if (basePlacement === "top" && top < padding) {
|
||||
const flippedTop = targetRect.bottom + offset;
|
||||
if (flippedTop + popoverRect.height <= viewportHeight - padding) {
|
||||
top = flippedTop;
|
||||
finalPlacement = placement.replace("top", "bottom");
|
||||
}
|
||||
} else if (basePlacement === "right" && left + popoverRect.width > viewportWidth - padding) {
|
||||
const flippedLeft = targetRect.left - popoverRect.width - offset;
|
||||
if (flippedLeft >= padding) {
|
||||
left = flippedLeft;
|
||||
finalPlacement = placement.replace("right", "left");
|
||||
}
|
||||
} else if (basePlacement === "left" && left < padding) {
|
||||
const flippedLeft = targetRect.right + offset;
|
||||
if (flippedLeft + popoverRect.width <= viewportWidth - padding) {
|
||||
left = flippedLeft;
|
||||
finalPlacement = placement.replace("left", "right");
|
||||
}
|
||||
}
|
||||
|
||||
if (left < padding) {
|
||||
left = padding;
|
||||
} else if (left + popoverRect.width > viewportWidth - padding) {
|
||||
left = viewportWidth - popoverRect.width - padding;
|
||||
}
|
||||
|
||||
if (top < padding) {
|
||||
top = padding;
|
||||
} else if (top + popoverRect.height > viewportHeight - padding) {
|
||||
top = viewportHeight - popoverRect.height - padding;
|
||||
}
|
||||
|
||||
return { top, left, placement: finalPlacement };
|
||||
}
|
||||
|
||||
interface SpotlightOverlayProps {
|
||||
// Solid's `h` auto-invokes zero-arg function props on read — these
|
||||
// are plain values inside the component body, not accessors.
|
||||
targetRect: DOMRect | null;
|
||||
hasTarget: boolean;
|
||||
padding: number;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
interface AnimatedRect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function SpotlightOverlay(props: SpotlightOverlayProps) {
|
||||
const [borderRadius, setBorderRadius] = createSignal(3.2);
|
||||
const [animatedRect, setAnimatedRect] = createSignal<AnimatedRect | null>(null);
|
||||
const [overlayOpacity, setOverlayOpacity] = createSignal(0);
|
||||
|
||||
createEffect(() => {
|
||||
const cssValue = getComputedStyle(document.documentElement).getPropertyValue("--radius-default").trim();
|
||||
if (cssValue) {
|
||||
const remValue = parseFloat(cssValue);
|
||||
if (!isNaN(remValue)) {
|
||||
const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
setBorderRadius(remValue * rootFontSize);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
requestAnimationFrame(() => setOverlayOpacity(1));
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const target = props.targetRect;
|
||||
if (target) {
|
||||
setAnimatedRect({
|
||||
left: target.left,
|
||||
top: target.top,
|
||||
width: target.width,
|
||||
height: target.height,
|
||||
});
|
||||
} else {
|
||||
setAnimatedRect(null);
|
||||
}
|
||||
});
|
||||
|
||||
const pad = () => props.padding ?? 8;
|
||||
const baseTransition = `all ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`;
|
||||
|
||||
return (
|
||||
<Show when={props.hasTarget && animatedRect()} fallback={
|
||||
<div class="fixed inset-0 bg-black/50 z-150" onclick={() => props.onclick?.()} style={{
|
||||
opacity: overlayOpacity(),
|
||||
transition: `opacity ${_TUTORIAL_ANIMATION_DURATION}ms ease-out`,
|
||||
}}/>
|
||||
}>
|
||||
<div class="fixed z-150 pointer-events-none rounded-default" style={(() => {
|
||||
const rect = animatedRect()!;
|
||||
return {
|
||||
left: (rect.left - pad()) + "px",
|
||||
top: (rect.top - pad()) + "px",
|
||||
width: (rect.width + pad() * 2) + "px",
|
||||
height: (rect.height + pad() * 2) + "px",
|
||||
"border-radius": borderRadius() + "px",
|
||||
"box-shadow": `0 0 0 9999px rgba(0, 0, 0, ${0.5 * overlayOpacity()})`,
|
||||
transition: baseTransition,
|
||||
};
|
||||
})()}>
|
||||
<div class="fixed inset-0 -z-10 cursor-pointer" onclick={() => props.onclick?.()}/>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
interface PopoverArrowProps {
|
||||
// Solid's `h` auto-invokes zero-arg function props on read.
|
||||
placement: string;
|
||||
}
|
||||
|
||||
function PopoverArrow(props: PopoverArrowProps) {
|
||||
const basePlacement = () => props.placement.split("-")[0];
|
||||
|
||||
const arrowStyles: Record<string, object> = {
|
||||
top: {
|
||||
bottom: "-8px", left: "50%", transform: "translateX(-50%)",
|
||||
"border-left": "8px solid transparent", "border-right": "8px solid transparent", "border-top": "8px solid white",
|
||||
},
|
||||
bottom: {
|
||||
top: "-8px", left: "50%", transform: "translateX(-50%)",
|
||||
"border-left": "8px solid transparent", "border-right": "8px solid transparent", "border-bottom": "8px solid white",
|
||||
},
|
||||
left: {
|
||||
right: "-8px", top: "50%", transform: "translateY(-50%)",
|
||||
"border-top": "8px solid transparent", "border-bottom": "8px solid transparent", "border-left": "8px solid white",
|
||||
},
|
||||
right: {
|
||||
left: "-8px", top: "50%", transform: "translateY(-50%)",
|
||||
"border-top": "8px solid transparent", "border-bottom": "8px solid transparent", "border-right": "8px solid white",
|
||||
},
|
||||
};
|
||||
|
||||
const borderArrowStyles: Record<string, object> = {
|
||||
top: {
|
||||
bottom: "-9px", left: "50%", transform: "translateX(-50%)",
|
||||
"border-left": "9px solid transparent", "border-right": "9px solid transparent", "border-top": "9px solid #e5e5e5",
|
||||
},
|
||||
bottom: {
|
||||
top: "-9px", left: "50%", transform: "translateX(-50%)",
|
||||
"border-left": "9px solid transparent", "border-right": "9px solid transparent", "border-bottom": "9px solid #e5e5e5",
|
||||
},
|
||||
left: {
|
||||
right: "-9px", top: "50%", transform: "translateY(-50%)",
|
||||
"border-top": "9px solid transparent", "border-bottom": "9px solid transparent", "border-left": "9px solid #e5e5e5",
|
||||
},
|
||||
right: {
|
||||
left: "-9px", top: "50%", transform: "translateY(-50%)",
|
||||
"border-top": "9px solid transparent", "border-bottom": "9px solid transparent", "border-right": "9px solid #e5e5e5",
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
<div class="absolute w-0 h-0" style={{
|
||||
...borderArrowStyles[basePlacement()],
|
||||
width: "0",
|
||||
height: "0",
|
||||
transition: `all ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`,
|
||||
}}/>,
|
||||
<div class="absolute w-0 h-0" style={{
|
||||
...arrowStyles[basePlacement()],
|
||||
transition: `all ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`,
|
||||
}}/>,
|
||||
];
|
||||
}
|
||||
|
||||
function TutorialPopover() {
|
||||
const ctx = useTutorialInternal();
|
||||
let popoverRef: HTMLDivElement | undefined;
|
||||
const [position, setPosition] = createSignal<{ top: number; left: number } | null>(null);
|
||||
const [currentPlacement, setCurrentPlacement] = createSignal("bottom");
|
||||
const [displayedPlacement, setDisplayedPlacement] = createSignal("bottom");
|
||||
const [isVisible, setIsVisible] = createSignal(false);
|
||||
const [isPositioned, setIsPositioned] = createSignal(false);
|
||||
const [contentOpacity, setContentOpacity] = createSignal(1);
|
||||
const [displayedStep, setDisplayedStep] = createSignal<TutorialStep | null>(ctx.currentStep());
|
||||
const [displayedStepIndex, setDisplayedStepIndex] = createSignal(ctx.currentStepIndex());
|
||||
const [showArrow, setShowArrow] = createSignal(false);
|
||||
|
||||
let prevStepIndex = ctx.currentStepIndex();
|
||||
let isTransitioning = false;
|
||||
|
||||
const placement = () => ctx.currentStep()?.placement ?? "bottom";
|
||||
const offset = () => ctx.currentStep()?.offset ?? 16;
|
||||
const hasTarget = () => !!ctx.currentStep()?.target;
|
||||
const displayHasTarget = () => !!displayedStep()?.target;
|
||||
|
||||
const updatePosition = (immediate: boolean = false) => {
|
||||
if (!popoverRef) return;
|
||||
const popoverRect = popoverRef.getBoundingClientRect();
|
||||
|
||||
let newTop, newLeft;
|
||||
let newPlacement = "bottom";
|
||||
|
||||
if (!hasTarget()) {
|
||||
newTop = (window.innerHeight - popoverRect.height) / 2;
|
||||
newLeft = (window.innerWidth - popoverRect.width) / 2;
|
||||
} else if (ctx.targetRect()) {
|
||||
const newPosition = calculatePopoverPosition(ctx.targetRect()!, popoverRect, placement(), offset());
|
||||
newTop = newPosition.top;
|
||||
newLeft = newPosition.left;
|
||||
newPlacement = newPosition.placement;
|
||||
} else {
|
||||
newTop = (window.innerHeight - popoverRect.height) / 2;
|
||||
newLeft = (window.innerWidth - popoverRect.width) / 2;
|
||||
}
|
||||
|
||||
setPosition({ top: newTop, left: newLeft });
|
||||
setCurrentPlacement(newPlacement);
|
||||
|
||||
if (immediate || !isPositioned()) {
|
||||
setDisplayedPlacement(newPlacement);
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (!popoverRef) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
updatePosition(true);
|
||||
setIsPositioned(true);
|
||||
requestAnimationFrame(() => {
|
||||
setIsVisible(true);
|
||||
setTimeout(() => setShowArrow(true), _TUTORIAL_ANIMATION_DURATION);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const currentIdx = ctx.currentStepIndex();
|
||||
if (prevStepIndex === currentIdx) return;
|
||||
|
||||
prevStepIndex = currentIdx;
|
||||
|
||||
if (isTransitioning) return;
|
||||
isTransitioning = true;
|
||||
|
||||
setIsFirstAppearance(false);
|
||||
setShowArrow(false);
|
||||
setContentOpacity(0);
|
||||
|
||||
setTimeout(() => {
|
||||
setDisplayedStep(ctx.currentStep());
|
||||
setDisplayedStepIndex(currentIdx);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
updatePosition(true);
|
||||
setTimeout(() => {
|
||||
setContentOpacity(1);
|
||||
setTimeout(() => {
|
||||
setShowArrow(true);
|
||||
isTransitioning = false;
|
||||
}, _TUTORIAL_ANIMATION_DURATION / 2);
|
||||
}, 50);
|
||||
});
|
||||
}, _TUTORIAL_ANIMATION_DURATION / 2);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
ctx.targetRect();
|
||||
if (!isTransitioning && isPositioned()) {
|
||||
updatePosition();
|
||||
setDisplayedPlacement(currentPlacement());
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!isPositioned()) return;
|
||||
|
||||
const handleUpdate = () => {
|
||||
if (!isTransitioning) {
|
||||
updatePosition();
|
||||
setDisplayedPlacement(currentPlacement());
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleUpdate, true);
|
||||
window.addEventListener("resize", handleUpdate);
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("scroll", handleUpdate, true);
|
||||
window.removeEventListener("resize", handleUpdate);
|
||||
});
|
||||
});
|
||||
|
||||
const isFirstStep = () => displayedStepIndex() === 0;
|
||||
const isLastStep = () => displayedStepIndex() === ctx.totalSteps() - 1;
|
||||
|
||||
const [isFirstAppearance, setIsFirstAppearance] = createSignal(true);
|
||||
|
||||
const getPopoverStyle = () => {
|
||||
const pos = position();
|
||||
if (!pos) {
|
||||
return {
|
||||
visibility: "hidden" as const,
|
||||
top: "-9999px",
|
||||
left: "-9999px",
|
||||
};
|
||||
}
|
||||
|
||||
if (isFirstAppearance()) {
|
||||
return {
|
||||
top: pos.top + "px",
|
||||
left: pos.left + "px",
|
||||
opacity: isVisible() ? 1 : 0,
|
||||
transform: isVisible() ? "scale(1)" : "scale(0.95)",
|
||||
transition: `opacity ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1), transform ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
top: pos.top + "px",
|
||||
left: pos.left + "px",
|
||||
opacity: isVisible() ? 1 : 0,
|
||||
transform: isVisible() ? "scale(1)" : "scale(0.95)",
|
||||
transition: `opacity ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1), transform ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1), top ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1), left ${_TUTORIAL_ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`,
|
||||
};
|
||||
};
|
||||
|
||||
const getContentStyle = () => ({
|
||||
opacity: contentOpacity(),
|
||||
transition: `opacity ${_TUTORIAL_ANIMATION_DURATION / 2}ms ease-out`,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={(el: HTMLDivElement) => popoverRef = el} class="fixed z-200 bg-surface rounded-default shadow-lg border border-line max-w-sm" style={getPopoverStyle()}>
|
||||
<Show when={displayHasTarget() && showArrow()}>
|
||||
<PopoverArrow placement={displayedPlacement()}/>
|
||||
</Show>
|
||||
|
||||
<div style={getContentStyle()}>
|
||||
<div class="flex items-center justify-between p-4 pb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={displayedStep()?.title}>
|
||||
<span class="font-medium text-ink">{displayedStep()?.title}</span>
|
||||
</Show>
|
||||
<span class="text-xs text-ink-muted">
|
||||
{(displayedStepIndex() + 1) + " of " + ctx.totalSteps()}
|
||||
</span>
|
||||
</div>
|
||||
<button onclick={() => ctx.end()} class="cursor-pointer text-ink-faint bg-transparent border-0 p-0 leading-none transition-colors hover:text-ink-soft">
|
||||
<Icon icon="xmark" size={18}/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-4 pb-4 text-sm text-ink">
|
||||
{displayedStep()?.content}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between px-4 pb-4 gap-2">
|
||||
<div>
|
||||
<Show when={!isFirstStep()}>
|
||||
<ButtonUI color={BUTTON_COLOR_WHITE} small onclick={() => ctx.previous()}>
|
||||
Previous
|
||||
</ButtonUI>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Show when={isLastStep()} fallback={
|
||||
<ButtonUI color={BUTTON_COLOR_BLUE} small onclick={() => ctx.next()}>
|
||||
Next
|
||||
</ButtonUI>
|
||||
}>
|
||||
<ButtonUI color={BUTTON_COLOR_BLUE} small onclick={() => ctx.end()}>
|
||||
Finish
|
||||
</ButtonUI>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={ctx.totalSteps() > 1}>
|
||||
<div class="flex justify-center gap-1.5 pb-3">
|
||||
<For each={Array.from({ length: ctx.totalSteps() })}>{(_, i) => (
|
||||
<div class={"w-2 h-2 rounded-full transition-all duration-300 ease-in-out " + (i() === displayedStepIndex() ? "bg-sky-600 scale-110" : "bg-surface-strong")}/>
|
||||
)}</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TutorialProvider(props: TutorialProviderProps) {
|
||||
const [isActive, setIsActive] = createSignal(false);
|
||||
const [currentStepIndex, setCurrentStepIndex] = createSignal(0);
|
||||
const [targetRect, setTargetRect] = createSignal<DOMRect | null>(null);
|
||||
|
||||
const totalSteps = () => props.steps.length;
|
||||
const currentStep = () => isActive() && props.steps[currentStepIndex()] ? props.steps[currentStepIndex()] : null;
|
||||
|
||||
createEffect(() => {
|
||||
if (!isActive() || !currentStep()) {
|
||||
setTargetRect(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const step = currentStep()!;
|
||||
if (!step.target) {
|
||||
setTargetRect(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const findTarget = (): HTMLElement | null => {
|
||||
if (typeof step.target === "function") {
|
||||
return step.target();
|
||||
}
|
||||
if (typeof step.target === "string") {
|
||||
return document.querySelector(step.target);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const updateTargetRect = () => {
|
||||
const target = findTarget();
|
||||
if (target) {
|
||||
setTargetRect(target.getBoundingClientRect());
|
||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
} else {
|
||||
setTargetRect(null);
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(updateTargetRect, 50);
|
||||
|
||||
window.addEventListener("scroll", updateTargetRect, true);
|
||||
window.addEventListener("resize", updateTargetRect);
|
||||
|
||||
onCleanup(() => {
|
||||
clearTimeout(timeoutId);
|
||||
window.removeEventListener("scroll", updateTargetRect, true);
|
||||
window.removeEventListener("resize", updateTargetRect);
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (isActive() && currentStep()?.onEnter) {
|
||||
currentStep()!.onEnter!();
|
||||
}
|
||||
});
|
||||
|
||||
const start = (stepIndex: number = 0) => {
|
||||
setCurrentStepIndex(Math.max(0, Math.min(stepIndex, props.steps.length - 1)));
|
||||
setIsActive(true);
|
||||
};
|
||||
|
||||
const end = () => {
|
||||
if (currentStep()?.onLeave) {
|
||||
currentStep()!.onLeave!();
|
||||
}
|
||||
setIsActive(false);
|
||||
setCurrentStepIndex(0);
|
||||
props.onEnd?.();
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
if (currentStep()?.onLeave) {
|
||||
currentStep()!.onLeave!();
|
||||
}
|
||||
if (currentStepIndex() < props.steps.length - 1) {
|
||||
setCurrentStepIndex(currentStepIndex() + 1);
|
||||
} else {
|
||||
end();
|
||||
}
|
||||
};
|
||||
|
||||
const previous = () => {
|
||||
if (currentStep()?.onLeave) {
|
||||
currentStep()!.onLeave!();
|
||||
}
|
||||
if (currentStepIndex() > 0) {
|
||||
setCurrentStepIndex(currentStepIndex() - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const goTo = (stepIndex: number) => {
|
||||
if (currentStep()?.onLeave) {
|
||||
currentStep()!.onLeave!();
|
||||
}
|
||||
setCurrentStepIndex(Math.max(0, Math.min(stepIndex, props.steps.length - 1)));
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (!isActive()) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
end();
|
||||
} else if (e.key === "ArrowRight" || e.key === "Enter") {
|
||||
next();
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
previous();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
onCleanup(() => document.removeEventListener("keydown", handleKeyDown));
|
||||
});
|
||||
|
||||
const publicValue = (): TutorialContextValue => ({
|
||||
isActive: isActive(),
|
||||
currentStepIndex: currentStepIndex(),
|
||||
totalSteps: props.steps.length,
|
||||
currentStep: currentStep(),
|
||||
start,
|
||||
end,
|
||||
next,
|
||||
previous,
|
||||
goTo,
|
||||
});
|
||||
|
||||
const internalValue: TutorialInternalContextValue = {
|
||||
isActive,
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
currentStep,
|
||||
targetRect,
|
||||
start,
|
||||
end,
|
||||
next,
|
||||
previous,
|
||||
goTo,
|
||||
};
|
||||
|
||||
return (
|
||||
<TutorialContext.Provider value={publicValue()}>
|
||||
<TutorialInternalContext.Provider value={internalValue}>
|
||||
{props.children}
|
||||
<Show when={isActive() && currentStep()}>
|
||||
<SpotlightOverlay targetRect={targetRect()} hasTarget={!!currentStep()?.target} padding={props.spotlightPadding ?? 8} onclick={() => end()}/>
|
||||
<TutorialPopover/>
|
||||
</Show>
|
||||
</TutorialInternalContext.Provider>
|
||||
</TutorialContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface StartTutorialButtonProps {
|
||||
stepIndex?: number;
|
||||
class?: string;
|
||||
children?: JSXElement;
|
||||
}
|
||||
|
||||
export function StartTutorialButton(props: StartTutorialButtonProps) {
|
||||
const { start } = useTutorial();
|
||||
|
||||
return (
|
||||
<ButtonUI color={BUTTON_COLOR_BLUE} onclick={() => start(props.stepIndex ?? 0)} class={props.class || ""}>
|
||||
{props.children ?? "Start Tutorial"}
|
||||
</ButtonUI>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user