Add fonts, autotable, autotable examples
This commit is contained in:
@@ -4,28 +4,68 @@ import (
|
||||
"strconv"
|
||||
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime"
|
||||
)
|
||||
|
||||
// Port of web/kit/Modal.tsx.
|
||||
// Port of web/uikit/Modal.tsx.
|
||||
//
|
||||
// NOTE: Solid's Portal (render to document.body) is dropped — modals render
|
||||
// inline where they are placed. Callers should mount them near the page root so
|
||||
// the fixed-position container is not clipped by an ancestor's overflow/transform.
|
||||
// NOTE: the entrance/exit animations (the isVisible signal plus the inline
|
||||
// opacity/scale transition styles) are dropped; the modal renders in its final
|
||||
// visible state.
|
||||
// NOTE: the imperative context (ModalProvider / useModal / openModal(content))
|
||||
// and the Escape-key handling (useModalEscape + the shared open-modal stack,
|
||||
// which needs a document keydown listener) are dropped — there is no context or
|
||||
// document access here. Use Modal with an IsOpen bool + OnClose callback instead.
|
||||
// NOTE: WizardStepContext (setCanContinue/nextStep/prevStep passed into each
|
||||
// step) is dropped. A WizardStep now carries a static Content node; the caller
|
||||
// drives CurrentStep/CanContinue via props. The openVersion reset-on-open memo
|
||||
// is likewise unnecessary here.
|
||||
// NOTE: the undefined-vs-null header/footer distinction collapses to nil — a nil
|
||||
// Header renders the close-only header (the undefined default); there is no
|
||||
// "explicitly no header" case. The "circle-exclamation" wizard-error icon is not
|
||||
// in the default registry and renders as an empty box until an app registers it.
|
||||
// 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
|
||||
@@ -43,6 +83,14 @@ const (
|
||||
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"
|
||||
@@ -106,27 +154,159 @@ const modalWizardBtnBack = "bg-transparent border border-neutral-300 text-neutra
|
||||
const modalWizardBtnNext = "bg-neutral-800 text-white enabled:hover:bg-neutral-900"
|
||||
const modalWizardBtnFinish = "bg-primary text-white enabled:hover:bg-red-700"
|
||||
|
||||
// modalCloseButton is the shared "✕" button (dismisses via onClose).
|
||||
func modalCloseButton(onClose func(), size int) *vdom.VNode {
|
||||
mods := []vdom.Mod{vdom.Attr("class", modalCloseBtn)}
|
||||
if onClose != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClose))
|
||||
// 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(),
|
||||
}
|
||||
mods = append(mods, Icon("xmark", size, ""))
|
||||
return vdom.El("button", mods...)
|
||||
}
|
||||
|
||||
func modalCloseOnlyHeader(onClose func()) *vdom.VNode {
|
||||
return vdom.El("div", vdom.Attr("class", modalHeaderCloseOnly), modalCloseButton(onClose, 24))
|
||||
// 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 modalFullHeader(header *vdom.VNode, onClose func()) *vdom.VNode {
|
||||
return vdom.El("div", vdom.Attr("class", modalHeader), header, modalCloseButton(onClose, 24))
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// modalDisplay is the dialog + backdrop + panel wrapper. Clicking the backdrop
|
||||
// invokes onClose.
|
||||
func modalDisplay(size ModalSize, centerOnScreen bool, onClose func(), children ...*vdom.VNode) *vdom.VNode {
|
||||
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
|
||||
@@ -135,23 +315,48 @@ func modalDisplay(size ModalSize, centerOnScreen bool, onClose func(), children
|
||||
size = ModalDefault
|
||||
}
|
||||
|
||||
backdrop := []vdom.Mod{vdom.Attr("class", modalBackdrop)}
|
||||
if onClose != nil {
|
||||
backdrop = append(backdrop, vdom.On(vdom.EVENT_CLICK, onClose))
|
||||
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.Attr("class", cx(modalBase, modalSizes[size]))}, children)
|
||||
panel := kids([]vdom.Mod{
|
||||
vdom.WithRef(m.panelRef),
|
||||
vdom.Attr("class", cx(modalBase, modalSizes[size])),
|
||||
vdom.Attr("style", modalPanelStyle),
|
||||
}, children)
|
||||
|
||||
return vdom.El("dialog",
|
||||
vdom.Attr("open", "open"),
|
||||
return vdom.Dialog(vdom.Attr("open", "open"),
|
||||
vdom.Attr("class", cx(modalContainerBase, align)),
|
||||
vdom.El("div", backdrop...),
|
||||
vdom.El("div", panel...),
|
||||
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 and ModalContent. A nil header renders the close-only header; a nil
|
||||
// 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
|
||||
@@ -160,56 +365,102 @@ func modalContentNodes(header, footer *vdom.VNode, onClose func(), children []*v
|
||||
} else {
|
||||
nodes = append(nodes, modalFullHeader(header, onClose))
|
||||
}
|
||||
nodes = append(nodes, vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", modalBody)}, children)...))
|
||||
nodes = append(nodes, vdom.Div(kids([]vdom.Mod{vdom.Attr("class", modalBody)}, children)...))
|
||||
if footer == nil {
|
||||
nodes = append(nodes, vdom.El("div", vdom.Attr("class", modalFooterSpacer)))
|
||||
nodes = append(nodes, vdom.Div(vdom.Attr("class", modalFooterSpacer)))
|
||||
} else {
|
||||
nodes = append(nodes, vdom.El("div", vdom.Attr("class", modalFooter), footer))
|
||||
nodes = append(nodes, vdom.Div(vdom.Attr("class", modalFooter), footer))
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
// ModalProps configures Modal. A nil Header renders a close-only header; a nil
|
||||
// Footer renders a spacer.
|
||||
type ModalProps struct {
|
||||
IsOpen bool
|
||||
OnClose func()
|
||||
Size ModalSize
|
||||
CenterOnScreen bool
|
||||
Header *vdom.VNode
|
||||
Footer *vdom.VNode
|
||||
// ---- 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()
|
||||
}
|
||||
|
||||
// Modal renders a dialog when IsOpen; otherwise it renders nothing (nil).
|
||||
func Modal(p ModalProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
if !p.IsOpen {
|
||||
return nil
|
||||
// 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()
|
||||
}
|
||||
nodes := modalContentNodes(p.Header, p.Footer, p.OnClose, children)
|
||||
return modalDisplay(p.Size, p.CenterOnScreen, p.OnClose, nodes...)
|
||||
return vdom.Portal(globalModal.display(globalModal.opts.Size, globalModal.opts.CenterOnScreen, globalContent()))
|
||||
}
|
||||
|
||||
// ModalContentProps configures ModalContent.
|
||||
type ModalContentProps struct {
|
||||
Header *vdom.VNode
|
||||
Footer *vdom.VNode
|
||||
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 to drop inside a
|
||||
// modalDisplay panel. The TSX returned a fragment; here it is wrapped in a
|
||||
// display:contents div so it adds no layout box.
|
||||
// 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 {
|
||||
nodes := modalContentNodes(p.Header, p.Footer, p.OnClose, children)
|
||||
mods := kids([]vdom.Mod{vdom.Attr("class", "contents")}, nodes)
|
||||
return vdom.El("div", mods...)
|
||||
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)...)
|
||||
}
|
||||
|
||||
// ConfirmModalProps configures ConfirmModal. ConfirmStyle is "danger" (default)
|
||||
// or "primary".
|
||||
// ---- confirm ----
|
||||
|
||||
// ConfirmModalProps configures Modal.Confirm. ConfirmStyle is "danger" (default) or
|
||||
// "primary".
|
||||
type ConfirmModalProps struct {
|
||||
IsOpen bool
|
||||
OnClose func()
|
||||
OnConfirm func()
|
||||
Title string
|
||||
Message string
|
||||
@@ -218,76 +469,154 @@ type ConfirmModalProps struct {
|
||||
ConfirmStyle string
|
||||
}
|
||||
|
||||
// ConfirmModal is a small centered modal with cancel/confirm buttons.
|
||||
func ConfirmModal(p ConfirmModalProps) *vdom.VNode {
|
||||
if !p.IsOpen {
|
||||
return nil
|
||||
// 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")
|
||||
style := pick(p.ConfirmStyle, "danger")
|
||||
okVariant := modalConfirmOkVariants[style]
|
||||
okVariant := modalConfirmOkVariants[pick(p.ConfirmStyle, "danger")]
|
||||
if okVariant == "" {
|
||||
okVariant = modalConfirmOkVariants["danger"]
|
||||
}
|
||||
|
||||
cancelMods := []vdom.Mod{vdom.Attr("class", modalConfirmCancel)}
|
||||
if p.OnClose != nil {
|
||||
cancelMods = append(cancelMods, vdom.On(vdom.EVENT_CLICK, p.OnClose))
|
||||
}
|
||||
cancelMods = append(cancelMods, vdom.Text(cancelText))
|
||||
|
||||
okMods := []vdom.Mod{
|
||||
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()
|
||||
}
|
||||
if p.OnClose != nil {
|
||||
p.OnClose()
|
||||
}
|
||||
m.Close()
|
||||
}),
|
||||
vdom.Text(confirmText),
|
||||
}
|
||||
|
||||
footer := vdom.El("div", vdom.Attr("class", modalConfirmWrap),
|
||||
vdom.El("button", cancelMods...),
|
||||
vdom.El("button", okMods...),
|
||||
)
|
||||
footer := vdom.Div(vdom.Attr("class", modalConfirmWrap), cancel, ok)
|
||||
|
||||
return Modal(ModalProps{
|
||||
IsOpen: true,
|
||||
OnClose: p.OnClose,
|
||||
Size: ModalSmall,
|
||||
CenterOnScreen: true,
|
||||
Header: vdom.Text(title),
|
||||
Footer: footer,
|
||||
}, vdom.Text(p.Message))
|
||||
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...))
|
||||
}
|
||||
|
||||
// WizardStep is one step of a WizardModal. Content is rendered statically (the
|
||||
// TSX's per-step callback context is dropped — see the file NOTE).
|
||||
// ---- 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 *vdom.VNode
|
||||
Content func(WizardStepContext) *vdom.VNode
|
||||
}
|
||||
|
||||
// WizardModalProps configures WizardModal. CurrentStep + OnStepChange drive
|
||||
// navigation; CanContinue gates the Next/Finish button for the current step.
|
||||
type WizardModalProps struct {
|
||||
IsOpen bool
|
||||
OnClose func()
|
||||
OnComplete func()
|
||||
Steps []WizardStep
|
||||
CurrentStep int
|
||||
OnStepChange func(int)
|
||||
CanContinue bool
|
||||
Size ModalSize
|
||||
CenterOnScreen bool
|
||||
Title string
|
||||
FinishText string
|
||||
Error string
|
||||
// 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 {
|
||||
@@ -308,22 +637,29 @@ func modalWizardNextBtnClass(isLast bool) string {
|
||||
return cx(modalWizardBtnBase, modalWizardBtnNext)
|
||||
}
|
||||
|
||||
// WizardModal is a multi-step modal with a progress header and Back/Next footer.
|
||||
func WizardModal(p WizardModalProps) *vdom.VNode {
|
||||
if !p.IsOpen {
|
||||
return nil
|
||||
// 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 := p.CurrentStep
|
||||
size := p.Size
|
||||
if size == "" {
|
||||
size = ModalLarge
|
||||
}
|
||||
finishText := pick(p.FinishText, "Finish")
|
||||
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 {
|
||||
@@ -342,8 +678,8 @@ func WizardModal(p WizardModalProps) *vdom.VNode {
|
||||
// Progress track + step indicators.
|
||||
stepsRow := []vdom.Mod{
|
||||
vdom.Attr("class", modalWizardSteps),
|
||||
vdom.El("div", vdom.Attr("class", modalWizardTrack),
|
||||
vdom.El("div", vdom.Attr("class", modalWizardTrackFill), vdom.Attr("style", "width:"+pctStr+"%")),
|
||||
vdom.Div(vdom.Attr("class", modalWizardTrack),
|
||||
vdom.Div(vdom.Attr("class", modalWizardTrackFill), vdom.Attr("style", "width:"+pctStr+"%")),
|
||||
),
|
||||
}
|
||||
for i := range steps {
|
||||
@@ -351,23 +687,25 @@ func WizardModal(p WizardModalProps) *vdom.VNode {
|
||||
if i < cur {
|
||||
label = "✓"
|
||||
}
|
||||
stepsRow = append(stepsRow, vdom.El("div", vdom.Attr("class", modalWizardStepWrap),
|
||||
vdom.El("div", vdom.Attr("class", modalStepIndicatorClass(i, cur)), vdom.Text(label)),
|
||||
stepsRow = append(stepsRow, vdom.Div(vdom.Attr("class", modalWizardStepWrap),
|
||||
vdom.Div(vdom.Attr("class", modalStepIndicatorClass(i, cur)), vdom.Text(label)),
|
||||
))
|
||||
}
|
||||
|
||||
titleRow := vdom.El("div", vdom.Attr("class", modalWizardTitleRow),
|
||||
vdom.El("span", vdom.Attr("class", modalWizardTitle), vdom.Text(title)),
|
||||
modalCloseButton(p.OnClose, 24),
|
||||
titleRow := vdom.Div(vdom.Attr("class", modalWizardTitleRow),
|
||||
vdom.Span(vdom.Attr("class", modalWizardTitle), vdom.Text(title)),
|
||||
modalCloseButton(w.modal.Close, 24),
|
||||
)
|
||||
wizardHeader := vdom.El("div", vdom.Attr("class", modalWizardHeader),
|
||||
wizardHeader := vdom.Div(vdom.Attr("class", modalWizardHeader),
|
||||
titleRow,
|
||||
vdom.El("div", vdom.Attr("class", modalWizardStepName), vdom.Text(currentStepTitle)),
|
||||
vdom.El("div", stepsRow...),
|
||||
vdom.Div(vdom.Attr("class", modalWizardStepName), vdom.Text(currentStepTitle)),
|
||||
vdom.Div(stepsRow...),
|
||||
)
|
||||
headerDiv := vdom.El("div", vdom.Attr("class", modalHeader), wizardHeader)
|
||||
headerDiv := vdom.Div(vdom.Attr("class", modalHeader), wizardHeader)
|
||||
|
||||
// Body: render every step, hiding the non-current ones.
|
||||
// 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{}
|
||||
@@ -375,58 +713,79 @@ func WizardModal(p WizardModalProps) *vdom.VNode {
|
||||
itemMods = append(itemMods, vdom.Attr("style", "display:none"))
|
||||
}
|
||||
if s.Content != nil {
|
||||
itemMods = append(itemMods, s.Content)
|
||||
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.El("div", itemMods...))
|
||||
bodyMods = append(bodyMods, vdom.Div(itemMods...))
|
||||
}
|
||||
|
||||
panel := []*vdom.VNode{headerDiv, vdom.El("div", bodyMods...)}
|
||||
panel := []*vdom.VNode{headerDiv, vdom.Div(bodyMods...)}
|
||||
|
||||
if p.Error != "" {
|
||||
panel = append(panel, vdom.El("div", vdom.Attr("class", modalWizardError),
|
||||
panel = append(panel, vdom.Div(vdom.Attr("class", modalWizardError),
|
||||
Icon("circle-exclamation", 16, modalWizardErrorIcon),
|
||||
vdom.El("span", vdom.Text(p.Error)),
|
||||
vdom.Span(vdom.Text(p.Error)),
|
||||
))
|
||||
}
|
||||
|
||||
// Footer: Back / Next(Finish).
|
||||
backMods := []vdom.Mod{vdom.Attr("class", cx(modalWizardBtnBase, modalWizardBtnBack))}
|
||||
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"))
|
||||
}
|
||||
if p.OnStepChange != nil {
|
||||
backMods = append(backMods, vdom.On(vdom.EVENT_CLICK, func() {
|
||||
if !isFirst {
|
||||
p.OnStepChange(cur - 1)
|
||||
}
|
||||
}))
|
||||
}
|
||||
backMods = append(backMods, vdom.Text("Back"))
|
||||
|
||||
nextMods := []vdom.Mod{vdom.Attr("class", modalWizardNextBtnClass(isLast))}
|
||||
if !p.CanContinue {
|
||||
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"))
|
||||
}
|
||||
nextMods = append(nextMods, vdom.On(vdom.EVENT_CLICK, func() {
|
||||
if isLast {
|
||||
if p.OnComplete != nil {
|
||||
p.OnComplete()
|
||||
}
|
||||
} else if p.OnStepChange != nil {
|
||||
p.OnStepChange(cur + 1)
|
||||
}
|
||||
}))
|
||||
nextLabel := "Next"
|
||||
if isLast {
|
||||
nextLabel = finishText
|
||||
nextLabel = pick(p.FinishText, "Finish")
|
||||
}
|
||||
nextMods = append(nextMods, vdom.Text(nextLabel))
|
||||
|
||||
footerInner := vdom.El("div", vdom.Attr("class", modalWizardFooter),
|
||||
vdom.El("button", backMods...),
|
||||
vdom.El("button", nextMods...),
|
||||
footerInner := vdom.Div(vdom.Attr("class", modalWizardFooter),
|
||||
vdom.Button(backMods...),
|
||||
vdom.Button(nextMods...),
|
||||
)
|
||||
panel = append(panel, vdom.El("div", vdom.Attr("class", modalFooter), footerInner))
|
||||
panel = append(panel, vdom.Div(vdom.Attr("class", modalFooter), footerInner))
|
||||
|
||||
return modalDisplay(size, p.CenterOnScreen, p.OnClose, panel...)
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user