initial port of the UI kit

This commit is contained in:
2026-07-13 01:58:29 -04:00
parent 261a7f2b4d
commit b02df48a66
51 changed files with 7507 additions and 154 deletions

432
go/webui/modal.go Normal file
View 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...)
}