Files
kjol/go/jsruntime/uikit/Tutorial.tsx

686 lines
24 KiB
TypeScript

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-ss 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>
);
}