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