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