initial port of the UI kit
This commit is contained in:
432
go/webui/modal.go
Normal file
432
go/webui/modal.go
Normal file
@@ -0,0 +1,432 @@
|
||||
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...)
|
||||
}
|
||||
Reference in New Issue
Block a user