Files
kjol/go/webui/modal.go

792 lines
27 KiB
Go

package webui
import (
"strconv"
"kjol/vdom"
"kjol/wasmruntime"
)
// Port of web/uikit/Modal.tsx.
//
// A modal is a *controller*, not a pure function: it owns the open flag, its slot
// on the shared modal stack (so Escape closes one layer per press), the refs it
// animates, and the document listener it installs. Create one per modal, ONCE,
// alongside your signals — never inside a render function, which would rebuild it
// (and its refs) every frame:
//
// edit := webui.NewModal(webui.ModalOptions{Size: webui.ModalMedium})
// return func() *vdom.VNode {
// return Div(
// webui.Button(webui.ButtonProps{Text: "Edit", OnClick: edit.Open}),
// edit.Render(webui.ModalProps{Header: Text("Edit user")}, form...),
// )
// }
//
// What the dialog does and why:
//
// 1. It renders PORTALED to document.body, so a modal opened from inside another
// modal's body is not clipped by the parent's `overflow: hidden` and does not
// have its `position: fixed` re-rooted by an ancestor `transform`.
// 2. Escape closes only the TOP modal (see the modal stack below), so nested
// modals unwind one layer per press.
// 3. Opening runs the enter animation imperatively: the panel is rendered at
// opacity 0 / scale(.95), then — after a DOUBLE requestAnimationFrame, which is
// what makes the browser commit that initial style before the target lands —
// SetStyle writes opacity 1 / scale(1) and the declared CSS transition
// interpolates. Writing this through a signal would re-render the entire app on
// every animation step.
//
// Faithful to the TSX (deliberately): the container is `<dialog open>` — the
// ATTRIBUTE, not .showModal() — so there is no native top layer, no ::backdrop and
// no native focus trap, and the original did no focus management of its own. None
// is invented here.
type Modal struct {
opts ModalOptions
open *vdom.Signal[bool]
panelRef *vdom.Ref
backdropRef *vdom.Ref
unsubs []wasmruntime.Unsub
}
// ModalOptions configures a Modal. The zero value is usable: default size, aligned
// to the top of the viewport, closes on Escape and on a backdrop click.
type ModalOptions struct {
Size ModalSize
CenterOnScreen bool
// OnClose fires after the modal closes, however it was dismissed (backdrop, the
// ✕ button, Escape, or a Close() call). The controller owns the open flag, so
// this is a notification — you do not have to mirror it in your own signal.
OnClose func()
KeepOnEscape bool // inverted: by default Escape closes the topmost modal
KeepOnBackdrop bool // inverted: by default a backdrop click closes
}
// ModalSize selects the modal panel's max width.
type ModalSize string
const (
ModalSmall ModalSize = "small"
ModalDefault ModalSize = "default"
ModalMedium ModalSize = "medium"
ModalLarge ModalSize = "large"
ModalXLarge ModalSize = "xlarge"
Modal2XLarge ModalSize = "2xlarge"
Modal3XLarge ModalSize = "3xlarge"
Modal4XLarge ModalSize = "4xlarge"
Modal5XLarge ModalSize = "5xlarge"
ModalFull ModalSize = "full"
)
// The enter animation (ANIMATION_DURATION = 100ms in the TSX). The initial styles
// are DECLARED on the element — so the reconciler emits an identical style attribute
// on every render and never clobbers the imperative writes (see updateAttrs, which
// only calls setAttribute when the declared value changes) — and the target styles
// are written imperatively after a double rAF.
const modalPanelStyle = "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)"
const modalBackdropStyle = "opacity:0;transition:opacity 100ms ease-out"
// -- Tailwind class constants --------------------------------------
const modalContainerBase = "fixed inset-0 z-[100] w-full h-dvh m-0 border-0 bg-transparent flex justify-center max-w-screen max-h-dvh"
const modalContainerTop = "items-start pt-10"
const modalContainerCenter = "items-center"
const modalBackdrop = "fixed inset-0 bg-black/30"
const modalBase = "relative bg-white shadow-md text-sm w-full rounded-default max-h-[calc(100dvh_-_5rem)] flex flex-col overflow-hidden"
var modalSizes = map[ModalSize]string{
ModalSmall: "max-w-md",
ModalDefault: "max-w-xl",
ModalMedium: "max-w-2xl",
ModalLarge: "max-w-3xl",
ModalXLarge: "max-w-4xl",
Modal2XLarge: "max-w-5xl",
Modal3XLarge: "max-w-6xl",
Modal4XLarge: "max-w-7xl",
Modal5XLarge: "max-w-[90rem]",
ModalFull: "max-w-none",
}
const modalHeader = "flex items-center justify-between py-5 px-7 pb-4 border-b border-neutral-200 text-lg font-semibold text-text-heading"
const modalHeaderCloseOnly = "flex items-center justify-end p-4 pb-1"
const modalCloseBtn = "cursor-pointer text-neutral-500 bg-transparent border-0 p-0 leading-none hover:text-neutral-700"
const modalBody = "px-7 py-6 overflow-y-auto flex-1 min-h-0 [background:linear-gradient(var(--color-white),var(--color-white))_bottom_/_100%_3rem_no-repeat_local,linear-gradient(to_bottom,transparent,var(--color-white))_bottom_/_100%_3rem_no-repeat_scroll,var(--color-white)]"
const modalFooter = "py-4 px-7 pb-[calc(1rem_+_env(safe-area-inset-bottom,0px))] flex flex-row justify-end gap-2 border-t border-neutral-200 bg-neutral-50 rounded-b-default"
const modalFooterSpacer = "h-2"
const modalWizardError = "flex items-center gap-2 py-3 px-8 text-sm text-red-700 bg-red-50 border-t border-red-200"
const modalWizardErrorIcon = "shrink-0 text-red-500"
// Confirm modal
const modalConfirmWrap = "flex justify-end gap-2"
const modalConfirmCancel = "py-2 px-4 text-sm border border-neutral-300 rounded-default bg-transparent cursor-pointer hover:bg-neutral-50"
const modalConfirmOkBase = "py-2 px-4 text-sm rounded-default border-0 cursor-pointer text-white"
var modalConfirmOkVariants = map[string]string{
"danger": "bg-red-600 hover:bg-red-700",
"primary": "bg-primary hover:bg-primary-hover",
}
// Wizard header
const modalWizardHeader = "flex flex-col items-center gap-2 flex-1"
const modalWizardTitleRow = "flex items-center justify-between w-full"
const modalWizardTitle = "text-xl"
const modalWizardStepName = "text-xs font-semibold text-neutral-600 uppercase tracking-wider"
const modalWizardSteps = "flex items-center justify-between relative w-full max-w-64"
const modalWizardTrack = "absolute top-1/2 left-0 right-0 h-0.5 bg-neutral-200 -translate-y-1/2"
const modalWizardTrackFill = "h-full bg-primary transition-[width] duration-300 ease-in-out"
const modalWizardStepWrap = "relative z-[1]"
const modalStepIndicatorBase = "w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold border-2 shrink-0 transition-all duration-300 ease-in-out"
const modalStepIndicatorPending = "border-neutral-300 text-neutral-400 bg-white"
const modalStepIndicatorActive = "bg-primary text-white border-primary"
const modalStepIndicatorCompleted = "bg-primary text-white border-primary"
// Wizard footer
const modalWizardFooter = "flex items-center justify-between w-full gap-2"
const modalWizardBtnBase = "py-2 px-5 text-sm rounded-default cursor-pointer border-0 disabled:opacity-40 disabled:cursor-not-allowed"
const modalWizardBtnBack = "bg-transparent border border-neutral-300 text-neutral-700 enabled:hover:bg-neutral-50"
const modalWizardBtnNext = "bg-neutral-800 text-white enabled:hover:bg-neutral-900"
const modalWizardBtnFinish = "bg-primary text-white enabled:hover:bg-red-700"
// NewModal creates a modal controller. Call it once, outside your render function.
func NewModal(o ModalOptions) *Modal {
return &Modal{
opts: o,
open: vdom.NewSignal(false),
panelRef: vdom.NewRef(),
backdropRef: vdom.NewRef(),
}
}
// IsOpen reports the current state. Safe to read during render.
func (m *Modal) IsOpen() bool { return m.open.Get() }
// Open, Close and Toggle drive the dialog. Safe to call from event handlers.
func (m *Modal) Open() {
if m.open.Get() {
return
}
modalStackPush(m)
if !m.opts.KeepOnEscape {
m.unsubs = append(m.unsubs, wasmruntime.OnDocument(vdom.EVENT_KEYDOWN, false, m.onKeydown))
}
m.open.Set(true)
// The dialog does not exist yet — the signal write only *scheduled* a render.
// Animate it in once it does.
wasmruntime.AfterRender(m.mounted)
}
func (m *Modal) Close() {
if !m.open.Get() {
return
}
m.teardown()
modalStackRemove(m)
m.open.Set(false)
if m.opts.OnClose != nil {
m.opts.OnClose()
}
}
func (m *Modal) Toggle() {
if m.open.Get() {
m.Close()
} else {
m.Open()
}
}
// Dispose closes the modal and removes every listener. Call it if the component
// owning this Modal goes away while the dialog might still be open.
func (m *Modal) Dispose() {
m.teardown()
modalStackRemove(m)
if m.open.Get() {
m.open.Set(false)
}
}
func (m *Modal) teardown() {
for _, un := range m.unsubs {
un()
}
m.unsubs = nil
}
// mounted runs once the dialog is in the DOM: play the enter animation.
//
// The double rAF is REQUIRED and not superstition: the target styles have to land
// in a LATER frame than the initial ones, or the browser never commits an initial
// value to interpolate from and the element simply appears. One frame is not
// enough (the render commit and the first rAF can share a frame).
func (m *Modal) mounted() {
// Re-assert the initial state before animating. Normally redundant (the declared
// style attribute already says this), but a re-opened modal can land on a DOM node
// the reconciler reused, which would still carry the imperative end state.
wasmruntime.SetStyle(m.backdropRef, "opacity", "0")
wasmruntime.SetStyle(m.panelRef, "opacity", "0")
wasmruntime.SetStyle(m.panelRef, "transform", "scale(0.95)")
wasmruntime.RAF(func() { wasmruntime.RAF(m.reveal) })
}
func (m *Modal) reveal() {
if !m.open.Get() {
return
}
wasmruntime.SetStyle(m.backdropRef, "opacity", "1")
wasmruntime.SetStyle(m.panelRef, "opacity", "1")
wasmruntime.SetStyle(m.panelRef, "transform", "scale(1)")
}
// onKeydown closes on Escape — but only the topmost modal, so a modal opened from
// inside another closes one layer per press instead of collapsing the whole stack.
func (m *Modal) onKeydown(ev vdom.Event) {
if ev.Key() != vdom.KEY_ESCAPE || !modalIsTopmost(m) {
return
}
ev.PreventDefault()
m.Close()
}
// ---- the modal stack ----
//
// The Go equivalent of the TSX's openModalStack: every open modal, in open order.
// Order is the whole point — the document keydown handler is installed once per open
// modal, and each one closes only if it is on top.
var modalStack []*Modal
func modalStackPush(m *Modal) { modalStack = append(modalStack, m) }
func modalStackRemove(m *Modal) {
for i, o := range modalStack {
if o == m {
modalStack = append(modalStack[:i], modalStack[i+1:]...)
return
}
}
}
func modalIsTopmost(m *Modal) bool {
return len(modalStack) > 0 && modalStack[len(modalStack)-1] == m
}
// ---- rendering ----
// ModalProps configures one render of a Modal. A nil Header renders the close-only
// header (the TSX's `undefined` default); a nil Footer renders the spacer. Size and
// alignment live on ModalOptions, because they belong to the modal, not to a frame
// of it.
type ModalProps struct {
Header *vdom.VNode
Footer *vdom.VNode
}
// Render draws the dialog when open, portaled to document.body.
//
// When closed it renders an EMPTY portal rather than nothing, so its slot in the
// parent's child list never disappears — the reconciler diffs children by index, and
// a vanishing child would shift every sibling after it.
func (m *Modal) Render(p ModalProps, children ...*vdom.VNode) *vdom.VNode {
if !m.open.Get() {
return vdom.Portal()
}
nodes := modalContentNodes(p.Header, p.Footer, m.Close, children)
return vdom.Portal(m.display(m.opts.Size, m.opts.CenterOnScreen, nodes...))
}
// display is the TSX's ModalDisplay: the `<dialog open>` container, the backdrop
// (clicking it closes), and the panel. Both animated elements carry a CONSTANT
// declared style — the initial state — which the enter animation then overwrites
// imperatively.
func (m *Modal) display(size ModalSize, centerOnScreen bool, children ...*vdom.VNode) *vdom.VNode {
align := modalContainerTop
if centerOnScreen {
align = modalContainerCenter
}
if size == "" {
size = ModalDefault
}
backdrop := []vdom.Mod{
vdom.WithRef(m.backdropRef),
vdom.Attr("class", modalBackdrop),
vdom.Attr("style", modalBackdropStyle),
}
if !m.opts.KeepOnBackdrop {
backdrop = append(backdrop, vdom.On(vdom.EVENT_CLICK, m.Close))
}
panel := kids([]vdom.Mod{
vdom.WithRef(m.panelRef),
vdom.Attr("class", cx(modalBase, modalSizes[size])),
vdom.Attr("style", modalPanelStyle),
}, children)
return vdom.Dialog(vdom.Attr("open", "open"),
vdom.Attr("class", cx(modalContainerBase, align)),
vdom.Div(backdrop...),
vdom.Div(panel...),
)
}
// modalCloseButton is the shared "✕" button (dismisses via onClose).
func modalCloseButton(onClose func(), size int) *vdom.VNode {
mods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", modalCloseBtn)}
if onClose != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClose))
}
mods = append(mods, Icon("xmark", size, ""))
return vdom.Button(mods...)
}
func modalCloseOnlyHeader(onClose func()) *vdom.VNode {
return vdom.Div(vdom.Attr("class", modalHeaderCloseOnly), modalCloseButton(onClose, 24))
}
func modalFullHeader(header *vdom.VNode, onClose func()) *vdom.VNode {
return vdom.Div(vdom.Attr("class", modalHeader), header, modalCloseButton(onClose, 24))
}
// modalContentNodes builds the header / body / footer panel children shared by
// Modal.Render and ModalContent. A nil header renders the close-only header; a nil
// footer renders the spacer.
func modalContentNodes(header, footer *vdom.VNode, onClose func(), children []*vdom.VNode) []*vdom.VNode {
var nodes []*vdom.VNode
if header == nil {
nodes = append(nodes, modalCloseOnlyHeader(onClose))
} else {
nodes = append(nodes, modalFullHeader(header, onClose))
}
nodes = append(nodes, vdom.Div(kids([]vdom.Mod{vdom.Attr("class", modalBody)}, children)...))
if footer == nil {
nodes = append(nodes, vdom.Div(vdom.Attr("class", modalFooterSpacer)))
} else {
nodes = append(nodes, vdom.Div(vdom.Attr("class", modalFooter), footer))
}
return nodes
}
// ---- the imperative opener (the TSX's ModalProvider / useModal / openModal) ----
//
// Go has no context, so "open a modal from anywhere" is a package-level store plus a
// host component the app renders ONCE, near the root of its tree:
//
// func App() *vdom.VNode {
// return Div(page(), webui.ModalHost())
// }
//
// and from anywhere at all:
//
// webui.OpenModal(func() *vdom.VNode {
// return webui.ModalContent(webui.ModalContentProps{Header: Text("Details")}, body...)
// }, webui.ModalOptions{Size: webui.ModalMedium})
//
// The content is a BUILDER, not a VNode: it is invoked on every render, so content
// that reads signals stays live — which is what the TSX got for free by storing an
// already-reactive JSXElement.
var (
globalModal = NewModal(ModalOptions{})
globalContent func() *vdom.VNode
// globalVersion exists only to force a re-render when the content is swapped
// while the modal is already open (the open signal would not change).
globalVersion = vdom.NewSignal(0)
)
// OpenModal opens the shared modal with the given content and options. Rendered by
// ModalHost. Calling it while a modal is already open swaps the content in place, as
// the TSX did (no second enter animation).
func OpenModal(content func() *vdom.VNode, o ModalOptions) {
user := o.OnClose
o.OnClose = func() {
globalContent = nil
if user != nil {
user()
}
}
globalModal.opts = o
globalContent = content
globalVersion.Update(func(v int) int { return v + 1 })
globalModal.Open()
}
// CloseModal closes the shared modal (the TSX's useModal().closeModal).
func CloseModal() { globalModal.Close() }
// ModalIsOpen reports whether the shared modal is open.
func ModalIsOpen() bool { return globalModal.IsOpen() }
// ModalHost renders the shared modal. Render it once, near the root of the app.
// Nothing (an empty portal) when no modal is open.
func ModalHost() *vdom.VNode {
_ = globalVersion.Get() // subscribe: a content swap must repaint the host
if !globalModal.IsOpen() || globalContent == nil {
return vdom.Portal()
}
return vdom.Portal(globalModal.display(globalModal.opts.Size, globalModal.opts.CenterOnScreen, globalContent()))
}
// ModalContentProps configures ModalContent.
type ModalContentProps struct {
Header *vdom.VNode
Footer *vdom.VNode
// OnClose overrides the default dismiss action (CloseModal, i.e. close the
// shared modal this content was opened into).
OnClose func()
}
// ModalContent renders the header / body / footer trio for content passed to
// OpenModal — the panel chrome that ModalHost's bare panel does not impose. The TSX
// returned a fragment; here it is wrapped in a display:contents div so it adds no
// layout box.
func ModalContent(p ModalContentProps, children ...*vdom.VNode) *vdom.VNode {
onClose := p.OnClose
if onClose == nil {
onClose = CloseModal
}
nodes := modalContentNodes(p.Header, p.Footer, onClose, children)
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "contents")}, nodes)...)
}
// ---- confirm ----
// ConfirmModalProps configures Modal.Confirm. ConfirmStyle is "danger" (default) or
// "primary".
type ConfirmModalProps struct {
OnConfirm func()
Title string
Message string
ConfirmText string
CancelText string
ConfirmStyle string
}
// Confirm renders this modal as a small, centred confirm dialog (as the TSX's
// ConfirmModal did — it hard-coded MODAL_SMALL + centerOnScreen). Confirming runs
// OnConfirm and then closes.
//
// del := webui.NewModal(webui.ModalOptions{})
// …
// del.Confirm(webui.ConfirmModalProps{Message: "Delete this row?", OnConfirm: doDelete})
func (m *Modal) Confirm(p ConfirmModalProps) *vdom.VNode {
if !m.open.Get() {
return vdom.Portal()
}
title := pick(p.Title, "Confirm")
confirmText := pick(p.ConfirmText, "Confirm")
cancelText := pick(p.CancelText, "Cancel")
okVariant := modalConfirmOkVariants[pick(p.ConfirmStyle, "danger")]
if okVariant == "" {
okVariant = modalConfirmOkVariants["danger"]
}
cancel := vdom.Button(vdom.Attr("type", "button"),
vdom.Attr("class", modalConfirmCancel),
vdom.On(vdom.EVENT_CLICK, m.Close),
vdom.Text(cancelText),
)
ok := vdom.Button(vdom.Attr("type", "button"),
vdom.Attr("class", cx(modalConfirmOkBase, okVariant)),
vdom.On(vdom.EVENT_CLICK, func() {
if p.OnConfirm != nil {
p.OnConfirm()
}
m.Close()
}),
vdom.Text(confirmText),
)
footer := vdom.Div(vdom.Attr("class", modalConfirmWrap), cancel, ok)
size := m.opts.Size
if size == "" {
size = ModalSmall
}
nodes := modalContentNodes(vdom.Text(title), footer, m.Close, []*vdom.VNode{vdom.Text(p.Message)})
return vdom.Portal(m.display(size, true, nodes...))
}
// ---- wizard ----
// WizardStepContext is what a step's content function is handed. Go has no context,
// so the TSX's WizardStepContext is threaded EXPLICITLY through the step render
// function instead — that is the whole shape of it:
//
// {Title: "Account", Content: func(ctx webui.WizardStepContext) *vdom.VNode {
// ctx.SetCanContinue(email.Get() != "") // gates this step's Next button
// return webui.TextInput(…)
// }}
//
// SetCanContinue is PER STEP (it writes the flag for ctx.Index), which is what the
// single caller-owned CanContinue bool could not express. It is safe to call during
// render: a write that does not change the flag does not schedule a render, so a
// step that asserts its own completeness every frame cannot loop.
type WizardStepContext struct {
// Index is this step's position in Steps.
Index int
// Version increments every time the wizard is opened. It is the Go stand-in for
// the TSX's openVersion memo, which re-instantiated each step's content on every
// open: Go step content is a plain function with no instance state to reset, so
// key any per-open state you keep yourself off this value.
Version int
SetCanContinue func(bool)
NextStep func()
PrevStep func()
}
// WizardStep is one step of a Wizard. Content is called on every render with this
// step's context.
type WizardStep struct {
Title string
Content func(WizardStepContext) *vdom.VNode
}
// Wizard is a multi-step modal: a progress header, one step's body at a time, and a
// Back / Next(Finish) footer. Like Modal it is a controller — create it once, outside
// render.
//
// It owns the step index and the per-step "can continue" flags; opening resets both
// (and bumps the version), which is the TSX's on-open reset effect.
type Wizard struct {
modal *Modal
step *vdom.Signal[int]
canContinue *vdom.Signal[[]bool]
version int
}
// WizardProps configures one render of a Wizard.
type WizardProps struct {
Steps []WizardStep
// Title overrides the header title; empty falls back to the current step's title.
Title string
FinishText string
// Error, when non-empty, renders the error strip above the footer.
Error string
OnComplete func()
}
// NewWizard creates a wizard controller. Size defaults to ModalLarge, as in the TSX.
func NewWizard(o ModalOptions) *Wizard {
if o.Size == "" {
o.Size = ModalLarge
}
return &Wizard{
modal: NewModal(o),
step: vdom.NewSignal(0),
canContinue: vdom.NewSignal[[]bool](nil),
}
}
// Open resets the wizard to step 0 with every step incomplete, bumps the version, and
// opens the dialog.
func (w *Wizard) Open() {
w.version++
w.step.Set(0)
w.canContinue.Set(nil)
w.modal.Open()
}
func (w *Wizard) Close() { w.modal.Close() }
func (w *Wizard) IsOpen() bool { return w.modal.IsOpen() }
func (w *Wizard) Dispose() { w.modal.Dispose() }
func (w *Wizard) Step() int { return w.step.Get() }
func (w *Wizard) Modal() *Modal { return w.modal }
// canContinueAt is the flag for step i (absent == not complete).
func (w *Wizard) canContinueAt(i int) bool {
flags := w.canContinue.Get()
return i >= 0 && i < len(flags) && flags[i]
}
// setCanContinue writes step i's flag. A no-op write is dropped, so content that
// calls ctx.SetCanContinue during render converges instead of re-rendering forever.
func (w *Wizard) setCanContinue(i int, v bool) {
if i < 0 || v == w.canContinueAt(i) {
return
}
flags := w.canContinue.Get()
next := make([]bool, max(len(flags), i+1))
copy(next, flags)
next[i] = v
w.canContinue.Set(next)
}
func modalStepIndicatorClass(i, cur int) string {
switch {
case i == cur:
return cx(modalStepIndicatorBase, modalStepIndicatorActive)
case i < cur:
return cx(modalStepIndicatorBase, modalStepIndicatorCompleted)
default:
return cx(modalStepIndicatorBase, modalStepIndicatorPending)
}
}
func modalWizardNextBtnClass(isLast bool) string {
if isLast {
return cx(modalWizardBtnBase, modalWizardBtnFinish)
}
return cx(modalWizardBtnBase, modalWizardBtnNext)
}
// Render draws the wizard when open (portaled, animated and Escape-closable like any
// other modal), and an empty portal when closed.
func (w *Wizard) Render(p WizardProps) *vdom.VNode {
if !w.modal.IsOpen() {
return vdom.Portal()
}
steps := p.Steps
total := len(steps)
cur := clampIndex(w.step.Get(), total)
isFirst := cur == 0
isLast := cur == total-1
next := func() {
if cur < total-1 {
w.step.Set(cur + 1)
}
}
prev := func() {
if cur > 0 {
w.step.Set(cur - 1)
}
}
title := p.Title
currentStepTitle := ""
if cur >= 0 && cur < total {
currentStepTitle = steps[cur].Title
if title == "" {
title = steps[cur].Title
}
}
pct := 100.0
if total > 1 {
pct = float64(cur) / float64(total-1) * 100
}
pctStr := strconv.FormatFloat(pct, 'f', -1, 64)
// Progress track + step indicators.
stepsRow := []vdom.Mod{
vdom.Attr("class", modalWizardSteps),
vdom.Div(vdom.Attr("class", modalWizardTrack),
vdom.Div(vdom.Attr("class", modalWizardTrackFill), vdom.Attr("style", "width:"+pctStr+"%")),
),
}
for i := range steps {
label := strconv.Itoa(i + 1)
if i < cur {
label = "✓"
}
stepsRow = append(stepsRow, vdom.Div(vdom.Attr("class", modalWizardStepWrap),
vdom.Div(vdom.Attr("class", modalStepIndicatorClass(i, cur)), vdom.Text(label)),
))
}
titleRow := vdom.Div(vdom.Attr("class", modalWizardTitleRow),
vdom.Span(vdom.Attr("class", modalWizardTitle), vdom.Text(title)),
modalCloseButton(w.modal.Close, 24),
)
wizardHeader := vdom.Div(vdom.Attr("class", modalWizardHeader),
titleRow,
vdom.Div(vdom.Attr("class", modalWizardStepName), vdom.Text(currentStepTitle)),
vdom.Div(stepsRow...),
)
headerDiv := vdom.Div(vdom.Attr("class", modalHeader), wizardHeader)
// Body: every step is rendered (with its own context), and the non-current ones
// are hidden — so a step's DOM, and anything the browser owns in it (scroll
// offsets, an open <select>), survives navigating away and back.
bodyMods := []vdom.Mod{vdom.Attr("class", modalBody)}
for i, s := range steps {
itemMods := []vdom.Mod{}
if i != cur {
itemMods = append(itemMods, vdom.Attr("style", "display:none"))
}
if s.Content != nil {
idx := i
content := s.Content(WizardStepContext{
Index: idx,
Version: w.version,
SetCanContinue: func(v bool) { w.setCanContinue(idx, v) },
NextStep: next,
PrevStep: prev,
})
if content != nil {
itemMods = append(itemMods, content)
}
}
bodyMods = append(bodyMods, vdom.Div(itemMods...))
}
panel := []*vdom.VNode{headerDiv, vdom.Div(bodyMods...)}
if p.Error != "" {
panel = append(panel, vdom.Div(vdom.Attr("class", modalWizardError),
Icon("circle-exclamation", 16, modalWizardErrorIcon),
vdom.Span(vdom.Text(p.Error)),
))
}
// Footer: Back / Next(Finish).
backMods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", cx(modalWizardBtnBase, modalWizardBtnBack)),
vdom.On(vdom.EVENT_CLICK, prev),
}
if isFirst {
backMods = append(backMods, vdom.Attr("disabled", "disabled"))
}
backMods = append(backMods, vdom.Text("Back"))
nextMods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", modalWizardNextBtnClass(isLast)),
vdom.On(vdom.EVENT_CLICK, func() {
if isLast {
if p.OnComplete != nil {
p.OnComplete()
}
return
}
next()
}),
}
if !w.canContinueAt(cur) {
nextMods = append(nextMods, vdom.Attr("disabled", "disabled"))
}
nextLabel := "Next"
if isLast {
nextLabel = pick(p.FinishText, "Finish")
}
nextMods = append(nextMods, vdom.Text(nextLabel))
footerInner := vdom.Div(vdom.Attr("class", modalWizardFooter),
vdom.Button(backMods...),
vdom.Button(nextMods...),
)
panel = append(panel, vdom.Div(vdom.Attr("class", modalFooter), footerInner))
return vdom.Portal(w.modal.display(w.modal.opts.Size, w.modal.opts.CenterOnScreen, panel...))
}
// clampIndex pins i into [0, n-1] (or 0 when there is nothing to index).
func clampIndex(i, n int) int {
if i < 0 || n <= 0 {
return 0
}
if i > n-1 {
return n - 1
}
return i
}