792 lines
27 KiB
Go
792 lines
27 KiB
Go
package webui
|
|
|
|
import (
|
|
"strconv"
|
|
|
|
"kjol/wasmruntime"
|
|
"kjol/wasmruntime/vdom"
|
|
)
|
|
|
|
// Port of jsruntime/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-surface 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-8xl",
|
|
ModalFull: "max-w-none",
|
|
}
|
|
|
|
const modalHeader = "flex items-center justify-between py-5 px-7 pb-4 border-b border-line text-lg font-semibold text-text-heading"
|
|
const modalHeaderCloseOnly = "flex items-center justify-end p-4 pb-1"
|
|
const modalCloseBtn = "cursor-pointer text-ink-muted bg-transparent border-0 p-0 leading-none hover:text-ink-soft"
|
|
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-line bg-surface-muted 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 dark:bg-red-950 border-t border-red-200 dark:border-red-900"
|
|
const modalWizardErrorIcon = "shrink-0 text-red-500 dark:text-red-400"
|
|
|
|
// Confirm modal
|
|
const modalConfirmWrap = "flex justify-end gap-2"
|
|
const modalConfirmCancel = "py-2 px-4 text-sm border border-line-strong rounded-default bg-transparent cursor-pointer hover:bg-surface-muted"
|
|
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-ss font-semibold text-ink-soft 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-surface-strong -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-ss font-semibold border-2 shrink-0 transition-all duration-300 ease-in-out"
|
|
const modalStepIndicatorPending = "border-line-strong text-ink-faint bg-surface"
|
|
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-line-strong text-ink-soft enabled:hover:bg-surface-muted"
|
|
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
|
|
}
|