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...) }