433 lines
15 KiB
Go
433 lines
15 KiB
Go
package webui
|
|
|
|
import (
|
|
"strconv"
|
|
|
|
"kjol/vdom"
|
|
)
|
|
|
|
// Port of web/kit/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.
|
|
|
|
// 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"
|
|
)
|
|
|
|
// -- 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"
|
|
|
|
// 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))
|
|
}
|
|
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))
|
|
}
|
|
|
|
func modalFullHeader(header *vdom.VNode, onClose func()) *vdom.VNode {
|
|
return vdom.El("div", vdom.Attr("class", modalHeader), header, modalCloseButton(onClose, 24))
|
|
}
|
|
|
|
// 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 {
|
|
align := modalContainerTop
|
|
if centerOnScreen {
|
|
align = modalContainerCenter
|
|
}
|
|
if size == "" {
|
|
size = ModalDefault
|
|
}
|
|
|
|
backdrop := []vdom.Mod{vdom.Attr("class", modalBackdrop)}
|
|
if onClose != nil {
|
|
backdrop = append(backdrop, vdom.On(vdom.EVENT_CLICK, onClose))
|
|
}
|
|
|
|
panel := kids([]vdom.Mod{vdom.Attr("class", cx(modalBase, modalSizes[size]))}, children)
|
|
|
|
return vdom.El("dialog",
|
|
vdom.Attr("open", "open"),
|
|
vdom.Attr("class", cx(modalContainerBase, align)),
|
|
vdom.El("div", backdrop...),
|
|
vdom.El("div", panel...),
|
|
)
|
|
}
|
|
|
|
// modalContentNodes builds the header / body / footer panel children shared by
|
|
// Modal 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.El("div", kids([]vdom.Mod{vdom.Attr("class", modalBody)}, children)...))
|
|
if footer == nil {
|
|
nodes = append(nodes, vdom.El("div", vdom.Attr("class", modalFooterSpacer)))
|
|
} else {
|
|
nodes = append(nodes, vdom.El("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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
nodes := modalContentNodes(p.Header, p.Footer, p.OnClose, children)
|
|
return modalDisplay(p.Size, p.CenterOnScreen, p.OnClose, nodes...)
|
|
}
|
|
|
|
// ModalContentProps configures ModalContent.
|
|
type ModalContentProps struct {
|
|
Header *vdom.VNode
|
|
Footer *vdom.VNode
|
|
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.
|
|
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...)
|
|
}
|
|
|
|
// ConfirmModalProps configures ConfirmModal. ConfirmStyle is "danger" (default)
|
|
// or "primary".
|
|
type ConfirmModalProps struct {
|
|
IsOpen bool
|
|
OnClose func()
|
|
OnConfirm func()
|
|
Title string
|
|
Message string
|
|
ConfirmText string
|
|
CancelText string
|
|
ConfirmStyle string
|
|
}
|
|
|
|
// ConfirmModal is a small centered modal with cancel/confirm buttons.
|
|
func ConfirmModal(p ConfirmModalProps) *vdom.VNode {
|
|
if !p.IsOpen {
|
|
return nil
|
|
}
|
|
title := pick(p.Title, "Confirm")
|
|
confirmText := pick(p.ConfirmText, "Confirm")
|
|
cancelText := pick(p.CancelText, "Cancel")
|
|
style := pick(p.ConfirmStyle, "danger")
|
|
okVariant := modalConfirmOkVariants[style]
|
|
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{
|
|
vdom.Attr("class", cx(modalConfirmOkBase, okVariant)),
|
|
vdom.On(vdom.EVENT_CLICK, func() {
|
|
if p.OnConfirm != nil {
|
|
p.OnConfirm()
|
|
}
|
|
if p.OnClose != nil {
|
|
p.OnClose()
|
|
}
|
|
}),
|
|
vdom.Text(confirmText),
|
|
}
|
|
|
|
footer := vdom.El("div", vdom.Attr("class", modalConfirmWrap),
|
|
vdom.El("button", cancelMods...),
|
|
vdom.El("button", okMods...),
|
|
)
|
|
|
|
return Modal(ModalProps{
|
|
IsOpen: true,
|
|
OnClose: p.OnClose,
|
|
Size: ModalSmall,
|
|
CenterOnScreen: true,
|
|
Header: vdom.Text(title),
|
|
Footer: footer,
|
|
}, vdom.Text(p.Message))
|
|
}
|
|
|
|
// WizardStep is one step of a WizardModal. Content is rendered statically (the
|
|
// TSX's per-step callback context is dropped — see the file NOTE).
|
|
type WizardStep struct {
|
|
Title string
|
|
Content *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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
steps := p.Steps
|
|
total := len(steps)
|
|
cur := p.CurrentStep
|
|
size := p.Size
|
|
if size == "" {
|
|
size = ModalLarge
|
|
}
|
|
finishText := pick(p.FinishText, "Finish")
|
|
isFirst := cur == 0
|
|
isLast := cur == total-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.El("div", vdom.Attr("class", modalWizardTrack),
|
|
vdom.El("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.El("div", vdom.Attr("class", modalWizardStepWrap),
|
|
vdom.El("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),
|
|
)
|
|
wizardHeader := vdom.El("div", vdom.Attr("class", modalWizardHeader),
|
|
titleRow,
|
|
vdom.El("div", vdom.Attr("class", modalWizardStepName), vdom.Text(currentStepTitle)),
|
|
vdom.El("div", stepsRow...),
|
|
)
|
|
headerDiv := vdom.El("div", vdom.Attr("class", modalHeader), wizardHeader)
|
|
|
|
// Body: render every step, hiding the non-current ones.
|
|
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 {
|
|
itemMods = append(itemMods, s.Content)
|
|
}
|
|
bodyMods = append(bodyMods, vdom.El("div", itemMods...))
|
|
}
|
|
|
|
panel := []*vdom.VNode{headerDiv, vdom.El("div", bodyMods...)}
|
|
|
|
if p.Error != "" {
|
|
panel = append(panel, vdom.El("div", vdom.Attr("class", modalWizardError),
|
|
Icon("circle-exclamation", 16, modalWizardErrorIcon),
|
|
vdom.El("span", vdom.Text(p.Error)),
|
|
))
|
|
}
|
|
|
|
// Footer: Back / Next(Finish).
|
|
backMods := []vdom.Mod{vdom.Attr("class", cx(modalWizardBtnBase, modalWizardBtnBack))}
|
|
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 = 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
|
|
}
|
|
nextMods = append(nextMods, vdom.Text(nextLabel))
|
|
|
|
footerInner := vdom.El("div", vdom.Attr("class", modalWizardFooter),
|
|
vdom.El("button", backMods...),
|
|
vdom.El("button", nextMods...),
|
|
)
|
|
panel = append(panel, vdom.El("div", vdom.Attr("class", modalFooter), footerInner))
|
|
|
|
return modalDisplay(size, p.CenterOnScreen, p.OnClose, panel...)
|
|
}
|