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-white rounded-default shadow-lg border border-neutral-200 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-neutral-900"), vdom.Text(step.Title))) } headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), 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-neutral-400 bg-transparent border-0 p-0 leading-none transition-colors hover:text-neutral-600"), vdom.On(vdom.EVENT_CLICK, t.End), Icon("xmark", 18, ""), ), ) // Body. bodyMods := []vdom.Mod{vdom.Attr("class", "px-4 pb-4 text-sm text-neutral-700")} 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-neutral-300" 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...) }