Files
kjol/go/webui/tutorial.go

773 lines
28 KiB
Go

package webui
import (
"strconv"
"kjol/vdom"
"kjol/wasmruntime"
)
// Port of web/uikit/Tutorial.tsx — a guided tour: it dims the page, cuts a
// spotlight hole around the step's target element, and floats a popover card with
// step navigation.
//
// Tutorial is an ENGINE, not a pure function. It owns everything the tour needs to
// know that a render cannot: where the target is (a measurement), how big the card
// is (another measurement), which frame the animation is on, and which timers are in
// flight. Create it ONCE, outside your render function:
//
// tour := webui.NewTutorial(webui.TutorialOptions{Steps: []webui.TutorialStep{
// {Title: "Filters", Target: "#filters", Placement: webui.PlacementRight,
// Content: func() *vdom.VNode { return Text("Narrow the table down here.") }},
// {Title: "Export", Target: "#export-btn",
// Content: func() *vdom.VNode { return Text("…then export what is left.") }},
// }})
//
// return func() *vdom.VNode {
// return Div(page(), tour.StartButton(0, ""), tour.Render())
// }
//
// How a step is staged, and why in that order:
//
// 1. The step's Target is a CSS selector — QuerySelector finds an element the tour
// does not own — resolved after a short delay, so a route transition or a layout
// settling does not hand us a stale box.
// 2. The target is measured and smooth-scrolled to the centre of the viewport.
// 3. The spotlight is a `position: fixed` box sized to that rect plus padding, with
// `box-shadow: 0 0 0 9999px rgba(0,0,0,0.5)`: the enormous spread IS the page
// dimming, and the box is the hole in it. Because its geometry is written with
// SetStyle onto an element that declares `transition: all 100ms`, the hole
// ANIMATES from one target to the next instead of teleporting.
// 4. The card is placed with ComputePosition (the same engine the floating layer
// uses — no hand-rolled second copy) and revealed. It re-positions on scroll
// (capture, because scroll does not bubble) and resize.
//
// Nothing here is browser-only in the Go sense: every host call stubs out natively,
// so on the server the tour is simply never active, Render emits an empty portal, and
// no measurement, listener or timer exists. There is no black screen to SSR.
type Tutorial struct {
opts TutorialOptions
active *vdom.Signal[bool]
// index is the live step; displayed is the step currently PAINTED. They differ
// only during a step transition, which double-buffers the card: the content is
// swapped at the midpoint of the cross-fade.
index *vdom.Signal[int]
displayed *vdom.Signal[int]
// placement is the RESOLVED placement (after ComputePosition may have flipped it).
// The arrow's side is a class/style decision, so it has to go through a render —
// hence a signal, written only when it actually changes.
placement *vdom.Signal[string]
showArrow *vdom.Signal[bool]
// spotlight is true when the step's target resolved AND has been measured; false
// means the flat-dim fallback (also the state a step with no target renders, and
// the only state the server can produce).
spotlight *vdom.Signal[bool]
popoverRef *vdom.Ref
contentRef *vdom.Ref
overlayRef *vdom.Ref
// Everything below is plain state, deliberately NOT signals: it feeds imperative
// style writes that run on every scroll frame, and a signal write there would
// re-render the whole app per frame.
rect wasmruntime.Rect
radius float64
positioned bool
transitioning bool
revealed bool // the overlay's dim has faded in on the current overlay node
firstAppearance bool
unsubs []wasmruntime.Unsub
targetTimer int
stepTimer int
arrowTimer int
}
// TutorialStep is one stop on the tour.
type TutorialStep struct {
Title string
// Content is called on every render, so content that reads signals stays live.
Content func() *vdom.VNode
// Target is a CSS selector for the element to spotlight. Empty means the step has
// no anchor: the page dims flat and the card centres in the viewport.
Target string
// Placement is one of the Placement* constants; default bottom. It may be flipped
// if the card does not fit on that side.
Placement string
// Offset is the gap between target and card in px; default 16.
Offset float64
OnEnter func()
OnLeave func()
}
// TutorialOptions configures a Tutorial. The zero value is usable (no steps).
type TutorialOptions struct {
Steps []TutorialStep
// SpotlightPadding is how far the cutout is inflated past the target; default 8.
SpotlightPadding float64
// OnEnd fires after the tour ends, however it ended (Finish, ✕, Escape, a click
// on the dim, or End()).
OnEnd func()
// Class is appended to the popover card.
Class string
}
// TSX constants: _TUTORIAL_ANIMATION_DURATION, the popover's default offset, and the
// beat we wait before measuring a target (long enough for a route transition or a
// layout to settle, short enough not to be seen).
const (
tutorialAnimMS = 100
tutorialHalfMS = tutorialAnimMS / 2
tutorialSettleMS = 50
tutorialTargetMS = 50
tutorialDefaultPad = 8.0
tutorialDefaultOff = 16.0
tutorialViewportPad = 16.0
// tutorialFallbackRadius is the TSX's initial borderRadius, used when
// --radius-default is unreadable (and on the server).
tutorialFallbackRadius = 3.2
)
// Declared styles. Each is CONSTANT across renders, which is what lets the imperative
// writes below survive reconciliation (the reconciler only re-sets an attribute whose
// declared value changed). They encode the pre-measurement state — the state the
// server renders and the browser paints for one frame before the first measurement.
const (
// The card: laid out (so it can be measured) but not painted, and faded/scaled
// down ready for the enter animation. No top/left transition yet — the first
// appearance must not slide in from 0,0.
tutorialPopoverStyle = "top:0;left:0;visibility:hidden;opacity:0;transform:scale(0.95);transition:opacity 100ms cubic-bezier(0.4, 0, 0.2, 1), transform 100ms cubic-bezier(0.4, 0, 0.2, 1)"
// From the first step change on, top/left transition too, so the card SLIDES
// between targets. Written imperatively when the first transition starts.
tutorialPopoverSlide = "opacity 100ms cubic-bezier(0.4, 0, 0.2, 1), transform 100ms cubic-bezier(0.4, 0, 0.2, 1), top 100ms cubic-bezier(0.4, 0, 0.2, 1), left 100ms cubic-bezier(0.4, 0, 0.2, 1)"
// The card's inner content, cross-faded on a step change (out, swap, in).
tutorialContentStyle = "opacity:1;transition:opacity 50ms ease-out"
// The spotlight: a zero-size box with a transparent 9999px shadow. The geometry
// and the shadow's alpha are both written imperatively; `transition: all` is what
// animates the hole between targets.
tutorialSpotlightStyle = "left:0;top:0;width:0;height:0;box-shadow:0 0 0 9999px rgba(0, 0, 0, 0);transition:all 100ms cubic-bezier(0.4, 0, 0.2, 1)"
tutorialSpotlightShadow = "0 0 0 9999px rgba(0, 0, 0, 0.5)"
// The no-target fallback: a flat dim that fades in.
tutorialBackdropStyle = "opacity:0;transition:opacity 100ms ease-out"
)
// Tailwind classes, verbatim from the TSX.
const (
tutorialPopoverClass = "fixed z-200 bg-surface rounded-default shadow-lg border border-line max-w-sm"
tutorialSpotlightClass = "fixed z-150 pointer-events-none rounded-default"
tutorialCatcherClass = "fixed inset-0 -z-10 cursor-pointer"
tutorialBackdropClass = "fixed inset-0 bg-black/50 z-150"
tutorialArrowClass = "absolute w-0 h-0"
)
// The arrow is two stacked CSS triangles: a 9px one in the border colour, and an 8px
// white one on top of it, which is what makes a bordered arrow out of two borders.
// Both are keyed by the RESOLVED base placement and hang off the OPPOSITE edge of the
// card — a card placed above its target carries its arrow on its bottom edge, pointing
// down at it.
var tutorialArrowOuter = map[string]string{
"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;",
}
var tutorialArrowInner = map[string]string{
"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 tutorialArrowTransition = "width:0;height:0;transition:all 100ms cubic-bezier(0.4, 0, 0.2, 1);"
// NewTutorial creates a tour engine. Call it once, outside your render function.
func NewTutorial(o TutorialOptions) *Tutorial {
if o.SpotlightPadding == 0 {
o.SpotlightPadding = tutorialDefaultPad
}
return &Tutorial{
opts: o,
active: vdom.NewSignal(false),
index: vdom.NewSignal(0),
displayed: vdom.NewSignal(0),
placement: vdom.NewSignal(PlacementBottom),
showArrow: vdom.NewSignal(false),
spotlight: vdom.NewSignal(false),
popoverRef: vdom.NewRef(),
contentRef: vdom.NewRef(),
overlayRef: vdom.NewRef(),
firstAppearance: true,
}
}
// SetSteps replaces the tour's steps (for a tour whose content depends on data that
// arrives later). Ends the tour if it is running.
func (t *Tutorial) SetSteps(steps []TutorialStep) {
if t.active.Get() {
t.End()
}
t.opts.Steps = steps
}
// IsActive, CurrentStep and TotalSteps are the tour's public state (the TSX's
// TutorialContextValue). Safe to read during render.
func (t *Tutorial) IsActive() bool { return t.active.Get() }
func (t *Tutorial) CurrentStep() int { return t.index.Get() }
func (t *Tutorial) TotalSteps() int { return len(t.opts.Steps) }
func (t *Tutorial) step(i int) *TutorialStep {
if i < 0 || i >= len(t.opts.Steps) {
return nil
}
return &t.opts.Steps[i]
}
// ---- driving the tour ----
// Start begins the tour at stepIndex (clamped). Starting an already-running tour just
// jumps to that step.
func (t *Tutorial) Start(stepIndex int) {
if len(t.opts.Steps) == 0 {
return
}
i := clampIndex(stepIndex, len(t.opts.Steps))
if t.active.Get() {
t.GoTo(i)
return
}
t.rect = wasmruntime.Rect{}
t.positioned = false
t.transitioning = false
t.revealed = false
t.firstAppearance = true
t.index.Set(i)
t.displayed.Set(i)
t.placement.Set(PlacementBottom)
t.showArrow.Set(false)
t.spotlight.Set(false)
t.active.Set(true)
if s := t.step(i); s != nil && s.OnEnter != nil {
s.OnEnter()
}
// The overlay and card do not exist yet — the signal writes only *scheduled* a
// render. Measure and animate once they do.
wasmruntime.AfterRender(t.mounted)
}
// End stops the tour.
func (t *Tutorial) End() {
if !t.active.Get() {
return
}
if s := t.step(t.index.Get()); s != nil && s.OnLeave != nil {
s.OnLeave()
}
t.teardown()
t.rect = wasmruntime.Rect{}
t.positioned = false
t.transitioning = false
t.revealed = false
t.firstAppearance = true
t.active.Set(false)
t.index.Set(0)
t.displayed.Set(0)
t.showArrow.Set(false)
t.spotlight.Set(false)
if t.opts.OnEnd != nil {
t.opts.OnEnd()
}
}
// Next advances, or ends the tour on the last step.
func (t *Tutorial) Next() {
if !t.active.Get() {
return
}
if i := t.index.Get(); i < len(t.opts.Steps)-1 {
t.GoTo(i + 1)
return
}
t.End()
}
// Previous steps back (a no-op on the first step).
func (t *Tutorial) Previous() {
if !t.active.Get() {
return
}
if i := t.index.Get(); i > 0 {
t.GoTo(i - 1)
}
}
// GoTo jumps to a step, running the leave/enter hooks and the transition choreography.
//
// The ORDER here is load-bearing, and it is the TSX's:
//
// 1. The target is re-resolved on a timer FIRST, so the new rect is in hand by the
// time the card asks where to go.
// 2. The card fades its content out, swaps the step at the midpoint of the fade,
// re-positions (now with top/left transitions on, so it slides), fades back in,
// and only then re-shows the arrow.
//
// While all that is in flight, `transitioning` suppresses scroll/resize
// re-positioning: the card is mid-slide and must not be yanked by a scroll frame
// (including the smooth scroll this very step just started).
func (t *Tutorial) GoTo(stepIndex int) {
if !t.active.Get() || len(t.opts.Steps) == 0 {
return
}
i := clampIndex(stepIndex, len(t.opts.Steps))
cur := t.index.Get()
if i == cur {
return
}
if s := t.step(cur); s != nil && s.OnLeave != nil {
s.OnLeave()
}
t.index.Set(i)
if s := t.step(i); s != nil && s.OnEnter != nil {
s.OnEnter()
}
// 1. the new target.
wasmruntime.ClearTimeout(t.targetTimer)
t.targetTimer = wasmruntime.SetTimeout(tutorialTargetMS, t.resolveTarget)
// 2. the card. A jump that lands mid-transition updates the index (and the target)
// but does not restart the choreography — the in-flight swap reads the LIVE
// index, so it lands on the newest step anyway.
if t.transitioning {
return
}
t.transitioning = true
t.firstAppearance = false
wasmruntime.SetStyle(t.popoverRef, "transition", tutorialPopoverSlide)
t.showArrow.Set(false)
wasmruntime.SetStyle(t.contentRef, "opacity", "0")
t.stepTimer = wasmruntime.SetTimeout(tutorialHalfMS, func() {
t.stepTimer = 0
if !t.active.Get() {
t.transitioning = false
return
}
t.displayed.Set(t.index.Get()) // swap at the midpoint of the fade
wasmruntime.AfterRender(func() {
// The new content is in the DOM: measure it and let the card slide.
t.updatePosition()
t.stepTimer = wasmruntime.SetTimeout(tutorialSettleMS, func() {
wasmruntime.SetStyle(t.contentRef, "opacity", "1")
t.stepTimer = wasmruntime.SetTimeout(tutorialHalfMS, func() {
t.stepTimer = 0
t.transitioning = false
t.showArrow.Set(true)
})
})
})
})
}
// Dispose ends the tour and removes every listener and timer.
func (t *Tutorial) Dispose() {
t.teardown()
if t.active.Get() {
t.active.Set(false)
}
}
func (t *Tutorial) teardown() {
for _, un := range t.unsubs {
un()
}
t.unsubs = nil
wasmruntime.ClearTimeout(t.targetTimer)
wasmruntime.ClearTimeout(t.stepTimer)
wasmruntime.ClearTimeout(t.arrowTimer)
t.targetTimer, t.stepTimer, t.arrowTimer = 0, 0, 0
}
// mounted runs once the overlay and card are in the DOM.
func (t *Tutorial) mounted() {
// The spotlight's corners should match the app's theme, so read the radius token
// rather than guessing. CSSVarPx resolves rem against the root font size.
t.radius = wasmruntime.CSSVarPx("--radius-default")
if t.radius <= 0 {
t.radius = tutorialFallbackRadius
}
t.unsubs = append(t.unsubs,
wasmruntime.OnDocument(vdom.EVENT_KEYDOWN, false, t.onKeydown),
// capture=true: `scroll` does not bubble, so this is the only way to hear a
// scroll inside a nested container the target happens to live in.
wasmruntime.OnWindow(vdom.EVENT_SCROLL, true, func(vdom.Event) { t.onViewportChange() }),
wasmruntime.OnWindow(vdom.EVENT_RESIZE, false, func(vdom.Event) { t.onViewportChange() }),
)
t.writeOverlay() // no rect yet: the flat dim, fading in
t.targetTimer = wasmruntime.SetTimeout(tutorialTargetMS, t.resolveTarget)
// Position on the next frame, reveal on the one after: the initial (faded, scaled)
// style must be COMMITTED before the target lands, or there is nothing for the
// browser to interpolate from and the card just appears.
wasmruntime.RAF(func() {
t.updatePosition()
t.positioned = true
wasmruntime.RAF(func() {
wasmruntime.SetStyle(t.popoverRef, "opacity", "1")
wasmruntime.SetStyle(t.popoverRef, "transform", "scale(1)")
t.arrowTimer = wasmruntime.SetTimeout(tutorialAnimMS, func() {
t.arrowTimer = 0
if t.active.Get() {
t.showArrow.Set(true)
}
})
})
})
}
// onKeydown: Escape ends the tour, ArrowRight/Enter advance, ArrowLeft goes back.
func (t *Tutorial) onKeydown(ev vdom.Event) {
switch ev.Key() {
case vdom.KEY_ESCAPE:
t.End()
case vdom.KEY_ARROW_RIGHT, vdom.KEY_ENTER:
t.Next()
case vdom.KEY_ARROW_LEFT:
t.Previous()
}
}
// ---- the target ----
// targetRef finds the current step's target in the document. The selector is
// re-queried on every measurement rather than cached, so a target that is re-rendered
// (a virtualised row, a re-keyed panel) does not leave the tour pointing at a detached
// node.
func (t *Tutorial) targetRef() *vdom.Ref {
s := t.step(t.index.Get())
if s == nil || s.Target == "" {
return nil
}
r := wasmruntime.QuerySelector(s.Target)
if !r.Mounted() {
return nil
}
return r
}
// resolveTarget runs once per step, on a short delay: measure the target and bring it
// into view. The smooth scroll is deliberately NOT repeated on every scroll frame (the
// TSX did, which fought the user for the scroll position); it belongs to entering a
// step.
func (t *Tutorial) resolveTarget() {
t.targetTimer = 0
if !t.active.Get() {
return
}
r := t.targetRef()
if r == nil {
t.rect = wasmruntime.Rect{}
t.applyOverlay()
return
}
t.rect = wasmruntime.Measure(r)
wasmruntime.ScrollIntoView(r, true, wasmruntime.ScrollBlockCenter)
t.applyOverlay()
if !t.transitioning && t.positioned {
t.updatePosition()
}
}
// onViewportChange re-measures on scroll and resize. Both the spotlight and the card
// follow the target — imperatively, so a scroll frame costs two measurements and a few
// style writes rather than a re-render of the app.
func (t *Tutorial) onViewportChange() {
if !t.active.Get() {
return
}
if r := t.targetRef(); r != nil {
t.rect = wasmruntime.Measure(r)
} else {
t.rect = wasmruntime.Rect{}
}
t.applyOverlay()
if !t.transitioning && t.positioned {
t.updatePosition()
}
}
// hasSpotlight reports whether there is a measured target to cut a hole around. A step
// with no target — or one whose selector matched nothing, or nothing laid out — falls
// back to the flat dim. On the server this is always false, which is why SSR ships a
// tour that is simply not running rather than a black screen.
func (t *Tutorial) hasSpotlight() bool {
s := t.step(t.index.Get())
return s != nil && s.Target != "" && !t.rect.Empty()
}
// ---- the overlay ----
// applyOverlay reconciles the overlay with the current measurement. Flipping between
// the cutout and the flat dim swaps the rendered node, so the styles have to be
// (re)written once that render has committed; when nothing flips, the write is direct.
func (t *Tutorial) applyOverlay() {
has := t.hasSpotlight()
if t.spotlight.Get() != has {
t.spotlight.Set(has)
t.revealed = false // a new node: it must fade its dim in again
wasmruntime.AfterRender(t.writeOverlay)
return
}
t.writeOverlay()
}
// writeOverlay writes the overlay's geometry, then fades its dim in the first time it
// runs on a given node. Every property of the mode NOT in use is removed, so a node the
// reconciler reused across a flip cannot keep a stale inline `left` and drag the flat
// backdrop off screen.
func (t *Tutorial) writeOverlay() {
if !t.active.Get() || !t.overlayRef.Mounted() {
return
}
if t.spotlight.Get() {
pad := t.opts.SpotlightPadding
wasmruntime.RemoveStyle(t.overlayRef, "opacity")
wasmruntime.SetStyle(t.overlayRef, "left", px(t.rect.Left()-pad))
wasmruntime.SetStyle(t.overlayRef, "top", px(t.rect.Top()-pad))
wasmruntime.SetStyle(t.overlayRef, "width", px(t.rect.Width+pad*2))
wasmruntime.SetStyle(t.overlayRef, "height", px(t.rect.Height+pad*2))
wasmruntime.SetStyle(t.overlayRef, "border-radius", px(t.radius))
} else {
for _, p := range []string{"left", "top", "width", "height", "border-radius", "box-shadow"} {
wasmruntime.RemoveStyle(t.overlayRef, p)
}
}
if t.revealed {
t.revealOverlay() // idempotent; keeps the dim applied on a freshly swapped node
return
}
t.revealed = true
// Double rAF, for the same reason the modal needs one: the transparent initial
// state has to be committed a frame before the dim lands, or it does not fade.
wasmruntime.RAF(func() { wasmruntime.RAF(t.revealOverlay) })
}
func (t *Tutorial) revealOverlay() {
if !t.active.Get() || !t.overlayRef.Mounted() {
return
}
if t.spotlight.Get() {
wasmruntime.SetStyle(t.overlayRef, "box-shadow", tutorialSpotlightShadow)
return
}
wasmruntime.SetStyle(t.overlayRef, "opacity", "1")
}
// ---- the card ----
// updatePosition measures the card, works out where it goes, and writes it. This is the
// only place the card's coordinates are decided, and it never goes through a signal: it
// runs on every scroll frame.
//
// The placement math is ComputePosition (position.go) — the same engine the floating
// layer uses. The TSX had its own fourth copy of it, which clamped on BOTH axes and so
// could push the card on top of the very thing it was pointing at; this one shifts on
// the cross axis only and flips on the main one.
func (t *Tutorial) updatePosition() {
if !t.popoverRef.Mounted() {
return
}
card := wasmruntime.Measure(t.popoverRef)
if card.Empty() {
return // not laid out yet; stay hidden rather than paint at 0,0
}
vp := wasmruntime.Viewport()
step := t.step(t.index.Get())
placement := PlacementBottom
var top, left float64
if step != nil && step.Target != "" && !t.rect.Empty() {
offset := step.Offset
if offset == 0 {
offset = tutorialDefaultOff
}
pos := ComputePosition(t.rect, card, vp, PositionOptions{
Placement: pick(step.Placement, PlacementBottom),
Offset: offset,
Flip: true,
Shift: true,
Padding: tutorialViewportPad,
})
top, left, placement = pos.Top, pos.Left, pos.Placement
} else {
// No target (or nothing measurable): centre the card in the viewport.
top = (vp.Height - card.Height) / 2
left = (vp.Width - card.Width) / 2
}
wasmruntime.SetStyle(t.popoverRef, "top", px(top))
wasmruntime.SetStyle(t.popoverRef, "left", px(left))
wasmruntime.SetStyle(t.popoverRef, "visibility", "visible")
// The arrow's side is a rendered decision, so a flip has to go through a render.
// Guarded on change, so this converges after one extra render instead of looping.
if t.placement.Get() != placement {
t.placement.Set(placement)
}
}
// ---- rendering ----
// Render draws the tour — the overlay and the card — portaled to document.body, so no
// ancestor's `overflow: hidden` can clip the spotlight and no ancestor `transform` can
// re-root its `position: fixed`. When the tour is not running it renders an EMPTY
// portal rather than nothing, so its slot in the parent's child list (which the
// reconciler diffs by index) never disappears.
func (t *Tutorial) Render() *vdom.VNode {
if !t.active.Get() || len(t.opts.Steps) == 0 {
return vdom.Portal()
}
return vdom.Portal(t.overlay(), t.card())
}
// overlay is either the spotlight cutout (a hole in a 9999px shadow) or, with no target
// to cut around, the flat dim. Both start transparent and are faded in imperatively.
func (t *Tutorial) overlay() *vdom.VNode {
if !t.spotlight.Get() {
return vdom.Div(vdom.WithRef(t.overlayRef),
vdom.Attr("class", tutorialBackdropClass),
vdom.Attr("style", tutorialBackdropStyle),
vdom.On(vdom.EVENT_CLICK, t.End),
)
}
return vdom.Div(vdom.WithRef(t.overlayRef),
vdom.Attr("class", tutorialSpotlightClass),
vdom.Attr("style", tutorialSpotlightStyle),
// The hole itself is pointer-events:none (you can still use what it spotlights);
// this sits behind the whole page and ends the tour when the dim is clicked.
vdom.Div(vdom.Attr("class", tutorialCatcherClass), vdom.On(vdom.EVENT_CLICK, t.End)),
)
}
// card renders the popover: the two arrow triangles, then the content (header, body,
// controls, dots) in its own cross-faded wrapper.
//
// The arrows are always rendered, and merely hidden with display:none when they are not
// wanted. That is deliberate: the reconciler diffs children BY INDEX, so adding and
// removing them would shift the content div's index and force it to be rebuilt —
// discarding the imperative opacity mid-cross-fade.
func (t *Tutorial) card() *vdom.VNode {
idx := clampIndex(t.displayed.Get(), len(t.opts.Steps))
step := t.step(idx)
total := len(t.opts.Steps)
base, _ := splitPlacement(t.placement.Get())
arrowVisible := t.showArrow.Get() && step != nil && step.Target != ""
hidden := ""
if !arrowVisible {
hidden = "display:none;"
}
outer := vdom.Div(vdom.Attr("class", tutorialArrowClass),
vdom.Attr("style", tutorialArrowOuter[base]+tutorialArrowTransition+hidden),
)
inner := vdom.Div(vdom.Attr("class", tutorialArrowClass),
vdom.Attr("style", tutorialArrowInner[base]+tutorialArrowTransition+hidden),
)
return vdom.Div(vdom.WithRef(t.popoverRef),
vdom.Attr("class", cx(tutorialPopoverClass, t.opts.Class)),
vdom.Attr("style", tutorialPopoverStyle),
outer,
inner,
t.content(step, idx, total),
)
}
func (t *Tutorial) content(step *TutorialStep, idx, total int) *vdom.VNode {
// Header: optional title + "X of N", and a close button.
headerLeft := []vdom.Mod{vdom.Attr("class", "flex items-center gap-2")}
if step != nil && step.Title != "" {
headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "font-medium text-ink"),
vdom.Text(step.Title)))
}
headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "text-xs text-ink-muted"),
vdom.Text(strconv.Itoa(idx+1)+" of "+strconv.Itoa(total))))
header := vdom.Div(vdom.Attr("class", "flex items-center justify-between p-4 pb-2"),
vdom.Div(headerLeft...),
vdom.Button(vdom.Attr("type", "button"),
vdom.Attr("class", "cursor-pointer text-ink-faint bg-transparent border-0 p-0 leading-none transition-colors hover:text-ink-soft"),
vdom.On(vdom.EVENT_CLICK, t.End),
Icon("xmark", 18, ""),
),
)
// Body.
bodyMods := []vdom.Mod{vdom.Attr("class", "px-4 pb-4 text-sm text-ink-soft")}
if step != nil && step.Content != nil {
if c := step.Content(); c != nil {
bodyMods = append(bodyMods, c)
}
}
body := vdom.Div(bodyMods...)
// Controls: Previous on the left (absent on the first step); Next/Finish on the right.
leftMods := []vdom.Mod{}
if idx > 0 {
leftMods = append(leftMods, Button(ButtonProps{Color: ButtonWhite, Small: true, Text: "Previous", OnClick: t.Previous}))
}
advance := Button(ButtonProps{Color: ButtonBlue, Small: true, Text: "Next", OnClick: t.Next})
if idx == total-1 {
advance = Button(ButtonProps{Color: ButtonBlue, Small: true, Text: "Finish", OnClick: t.End})
}
controls := vdom.Div(vdom.Attr("class", "flex items-center justify-between px-4 pb-4 gap-2"),
vdom.Div(leftMods...),
vdom.Div(vdom.Attr("class", "flex gap-2"), advance),
)
contentMods := []vdom.Mod{
vdom.WithRef(t.contentRef),
vdom.Attr("style", tutorialContentStyle),
header, body, controls,
}
// Step dots.
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-surface-strong"
if i == idx {
state = "bg-sky-600 scale-110"
}
dotMods = append(dotMods, vdom.Div(vdom.Attr("class", cx("w-2 h-2 rounded-full transition-all duration-300 ease-in-out", state))))
}
contentMods = append(contentMods, vdom.Div(dotMods...))
}
return vdom.Div(contentMods...)
}
// StartButton is the blue button that kicks the tour off at stepIndex. With no children
// it renders the default "Start Tutorial" label.
func (t *Tutorial) StartButton(stepIndex int, class string, children ...*vdom.VNode) *vdom.VNode {
bp := ButtonProps{Color: ButtonBlue, Class: class, OnClick: func() { t.Start(stepIndex) }}
if len(children) == 0 {
bp.Text = "Start Tutorial"
}
return Button(bp, children...)
}