189 lines
7.6 KiB
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...)
|
|
}
|