Files
kjol/go/webui/tutorial.go

189 lines
7.6 KiB
Go

package webui
import (
"strconv"
"kjol/vdom"
)
// Port of web/kit/Tutorial.tsx.
//
// Tutorial is a guided-tour / coachmark overlay: it dims the page, spotlights a
// target element, and floats a popover card with step navigation. The Solid
// source leans heavily on browser-only capabilities the neutral vdom runtime
// does not have (DOM measurement via getBoundingClientRect, portals, effects,
// timers, scroll/resize listeners, window sizing). What is ported vs. dropped:
//
// - NOTE: calculatePopoverPosition — the viewport-aware placement math that
// anchors and flips the popover around the target rect — is DROPPED. The
// popover is statically centered with Tailwind instead of computed coords.
// - NOTE: SpotlightOverlay's measured cutout (a giant box-shadow ring drawn
// around the target's DOMRect) is APPROXIMATED by a plain dimmed backdrop,
// which is the source's own no-target fallback.
// - NOTE: PopoverArrow (the little triangle pointing at the target) is DROPPED,
// since there is no target position to point at.
// - NOTE: the fade/scale/slide transitions and the requestAnimationFrame /
// setTimeout choreography are DROPPED; the card renders in its final state.
// - NOTE: Solid context (TutorialProvider/useTutorial) + signals collapse to
// plain props — the caller owns the active flag, the current-step index, and
// the next/prev/close callbacks (read at the call site, as per kit convention).
// - NOTE: per-step onEnter/onLeave lifecycle hooks (effect-driven) are DROPPED,
// as is the string/function/null target union — Target here is a plain CSS
// selector kept for reference only (nothing measures or scrolls to it).
// - NOTE: SpotlightPadding, Placement, and Offset are retained for API parity
// but are unused, because there is no positioning/spotlight to apply them to.
// TutorialStep is one stop in a guided tour. Content is the body VNode (the TSX
// JSXElement). Target is the CSS selector of the element the step would spotlight
// (see file NOTE — not measured here). Placement/Offset are kept for API parity
// but are not applied.
type TutorialStep struct {
Title string
Content *vdom.VNode
Target string
Placement string
Offset int
}
// TutorialProps drives the tour overlay. CurrentStep is the active index as a
// plain value (the state that was a Solid signal now lives with the caller).
// Active gates whether the overlay renders at all. OnNext/OnPrev advance the
// tour; OnClose ends it (shared by the backdrop click, the close button, and the
// Finish button on the last step).
type TutorialProps struct {
Steps []TutorialStep
CurrentStep int
Active bool
SpotlightPadding int
OnNext func()
OnPrev func()
OnClose func()
Class string
}
// tutorialOverlay is the dimmed backdrop. NOTE: this approximates SpotlightOverlay
// without the measured spotlight cutout (see file NOTE); clicking it ends the tour.
func tutorialOverlay(p TutorialProps) *vdom.VNode {
mods := []vdom.Mod{vdom.Attr("class", "fixed inset-0 bg-black/50 z-150")}
if p.OnClose != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnClose))
}
return vdom.El("div", mods...)
}
// tutorialPopover renders the tooltip card (title + "X of N" + close, body,
// prev/next controls, and the step dots) for step idx. NOTE: it is statically
// centered rather than anchored to the target (see file NOTE).
func tutorialPopover(p TutorialProps, idx int) *vdom.VNode {
step := p.Steps[idx]
total := len(p.Steps)
// Header: optional title + step counter, and a close button.
headerLeft := []vdom.Mod{vdom.Attr("class", "flex items-center gap-2")}
if step.Title != "" {
headerLeft = append(headerLeft, vdom.El("span",
vdom.Attr("class", "font-medium text-neutral-900"),
vdom.Text(step.Title)))
}
headerLeft = append(headerLeft, vdom.El("span",
vdom.Attr("class", "text-xs text-neutral-500"),
vdom.Text(strconv.Itoa(idx+1)+" of "+strconv.Itoa(total))))
closeMods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", "cursor-pointer text-neutral-400 bg-transparent border-0 p-0 leading-none transition-colors hover:text-neutral-600"),
Icon("xmark", 18, ""),
}
if p.OnClose != nil {
closeMods = append(closeMods, vdom.On(vdom.EVENT_CLICK, p.OnClose))
}
header := vdom.El("div", vdom.Attr("class", "flex items-center justify-between p-4 pb-2"),
vdom.El("div", headerLeft...),
vdom.El("button", closeMods...),
)
// Body content.
bodyMods := []vdom.Mod{vdom.Attr("class", "px-4 pb-4 text-sm text-neutral-700")}
if step.Content != nil {
bodyMods = append(bodyMods, step.Content)
}
body := vdom.El("div", bodyMods...)
// Controls: Previous on the left (hidden on the first step); Next/Finish right.
isFirst := idx == 0
isLast := idx == total-1
leftMods := []vdom.Mod{}
if !isFirst {
leftMods = append(leftMods, Button(ButtonProps{Color: ButtonWhite, Small: true, Text: "Previous", OnClick: p.OnPrev}))
}
var advance *vdom.VNode
if isLast {
advance = Button(ButtonProps{Color: ButtonBlue, Small: true, Text: "Finish", OnClick: p.OnClose})
} else {
advance = Button(ButtonProps{Color: ButtonBlue, Small: true, Text: "Next", OnClick: p.OnNext})
}
controls := vdom.El("div", vdom.Attr("class", "flex items-center justify-between px-4 pb-4 gap-2"),
vdom.El("div", leftMods...),
vdom.El("div", vdom.Attr("class", "flex gap-2"), advance),
)
// NOTE: original card class kept verbatim; the centering utilities
// (left/top/-translate) are the static stand-in for computed positioning.
popMods := []vdom.Mod{
vdom.Attr("class", cx("fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-200 bg-white rounded-default shadow-lg border border-neutral-200 max-w-sm", p.Class)),
header, body, controls,
}
// Step dots (only when there is more than one step).
if total > 1 {
dotMods := []vdom.Mod{vdom.Attr("class", "flex justify-center gap-1.5 pb-3")}
for i := 0; i < total; i++ {
state := "bg-neutral-300"
if i == idx {
state = "bg-sky-600 scale-110"
}
dotMods = append(dotMods, vdom.El("div",
vdom.Attr("class", cx("w-2 h-2 rounded-full transition-all duration-300 ease-in-out", state))))
}
popMods = append(popMods, vdom.El("div", dotMods...))
}
return vdom.El("div", popMods...)
}
// TutorialProvider renders children, and — when the tour is Active — the dimmed
// backdrop plus the popover card for the current step over the top. The wrapper
// uses display:contents so it does not introduce its own layout box (the Solid
// source returned a fragment). Returns just the children when inactive or empty.
//
// NOTE: unlike the Solid provider, children cannot read tour state via context;
// the caller threads Active/CurrentStep/callbacks in through TutorialProps.
func TutorialProvider(p TutorialProps, children ...*vdom.VNode) *vdom.VNode {
mods := kids([]vdom.Mod{vdom.Attr("class", "contents")}, children)
if p.Active && len(p.Steps) > 0 {
idx := p.CurrentStep
if idx < 0 {
idx = 0
}
if idx > len(p.Steps)-1 {
idx = len(p.Steps) - 1
}
mods = append(mods, tutorialOverlay(p), tutorialPopover(p, idx))
}
return vdom.El("div", mods...)
}
// StartTutorialButton is a blue button that kicks off the tour. onStart is the
// caller's start handler (which decides the starting step index). With no
// children it renders the default "Start Tutorial" label.
func StartTutorialButton(onStart func(), class string, children ...*vdom.VNode) *vdom.VNode {
bp := ButtonProps{Color: ButtonBlue, Class: class, OnClick: onStart}
if len(children) == 0 {
bp.Text = "Start Tutorial"
}
return Button(bp, children...)
}