Add fonts, autotable, autotable examples
This commit is contained in:
@@ -4,138 +4,748 @@ import (
|
||||
"strconv"
|
||||
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime"
|
||||
)
|
||||
|
||||
// Port of web/kit/Tutorial.tsx.
|
||||
// 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 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:
|
||||
// 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:
|
||||
//
|
||||
// - 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.
|
||||
// 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
|
||||
|
||||
// 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.
|
||||
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 *vdom.VNode
|
||||
Target string
|
||||
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 int
|
||||
// Offset is the gap between target and card in px; default 16.
|
||||
Offset float64
|
||||
|
||||
OnEnter func()
|
||||
OnLeave func()
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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))
|
||||
// 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,
|
||||
}
|
||||
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)
|
||||
// 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
|
||||
}
|
||||
|
||||
// Header: optional title + step counter, and a close button.
|
||||
// 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.Title != "" {
|
||||
headerLeft = append(headerLeft, vdom.El("span",
|
||||
vdom.Attr("class", "font-medium text-neutral-900"),
|
||||
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.El("span",
|
||||
vdom.Attr("class", "text-xs text-neutral-500"),
|
||||
headerLeft = append(headerLeft, vdom.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...),
|
||||
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 content.
|
||||
// Body.
|
||||
bodyMods := []vdom.Mod{vdom.Attr("class", "px-4 pb-4 text-sm text-neutral-700")}
|
||||
if step.Content != nil {
|
||||
bodyMods = append(bodyMods, step.Content)
|
||||
if step != nil && step.Content != nil {
|
||||
if c := step.Content(); c != nil {
|
||||
bodyMods = append(bodyMods, c)
|
||||
}
|
||||
}
|
||||
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
|
||||
body := vdom.Div(bodyMods...)
|
||||
|
||||
// Controls: Previous on the left (absent on the first step); Next/Finish on the right.
|
||||
leftMods := []vdom.Mod{}
|
||||
if !isFirst {
|
||||
leftMods = append(leftMods, Button(ButtonProps{Color: ButtonWhite, Small: true, Text: "Previous", OnClick: p.OnPrev}))
|
||||
if idx > 0 {
|
||||
leftMods = append(leftMods, Button(ButtonProps{Color: ButtonWhite, Small: true, Text: "Previous", OnClick: t.Previous}))
|
||||
}
|
||||
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})
|
||||
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.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),
|
||||
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),
|
||||
)
|
||||
|
||||
// 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)),
|
||||
contentMods := []vdom.Mod{
|
||||
vdom.WithRef(t.contentRef),
|
||||
vdom.Attr("style", tutorialContentStyle),
|
||||
header, body, controls,
|
||||
}
|
||||
|
||||
// Step dots (only when there is more than one step).
|
||||
// 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++ {
|
||||
@@ -143,44 +753,18 @@ func tutorialPopover(p TutorialProps, idx int) *vdom.VNode {
|
||||
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))))
|
||||
dotMods = append(dotMods, vdom.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...))
|
||||
contentMods = append(contentMods, vdom.Div(dotMods...))
|
||||
}
|
||||
|
||||
return vdom.El("div", popMods...)
|
||||
return vdom.Div(contentMods...)
|
||||
}
|
||||
|
||||
// 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}
|
||||
// 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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user