package webui import ( "strconv" "kjol/vdom" "kjol/wasmruntime" ) // Port of web/uikit/Toast.tsx. // // Two ways in, depending on who owns the queue: // // - Toaster (below) is the one you want. It owns the list, generates IDs, runs the // auto-dismiss timers, and animates the countdown bar. It is the Go stand-in for // the TSX's useToast context. // - ToastProvider is the dumb half: it renders a list you hand it and calls you // back on dismiss. Use it only if you already own the queue — and remember that // NOTHING will remove a toast for you. // ToastType selects a toast's accent color and leading icon. type ToastType string const ( ToastSuccess ToastType = "success" ToastError ToastType = "error" ToastWarning ToastType = "warning" ToastInfo ToastType = "info" ToastGeneric ToastType = "generic" ) // ToastPosition is where the container is anchored on screen. type ToastPosition string const ( ToastTopRight ToastPosition = "top-right" ToastTopLeft ToastPosition = "top-left" ToastBottomRight ToastPosition = "bottom-right" ToastBottomLeft ToastPosition = "bottom-left" ToastTopCenter ToastPosition = "top-center" ToastBottomCenter ToastPosition = "bottom-center" ) // Toast is one notification. Duration is in ms (0 = no auto-dismiss and no // progress bar). Dismissible shows the close button; ShowProgress shows the // (static) progress bar when Duration > 0. Type defaults to ToastInfo. type Toast struct { ID string Message string Type ToastType Duration int Dismissible bool ShowProgress bool } var toastTypeIcons = map[ToastType]string{ ToastSuccess: "circle-check", ToastError: "circle-xmark", ToastWarning: "triangle-exclamation", ToastInfo: "circle-info", ToastGeneric: "", } const toastContainerBase = "fixed z-[200] flex flex-col gap-2" var toastContainerPositions = map[ToastPosition]string{ ToastTopRight: "top-4 right-4", ToastTopLeft: "top-4 left-4", ToastBottomRight: "bottom-4 right-4 flex-col-reverse", ToastBottomLeft: "bottom-4 left-4 flex-col-reverse", ToastTopCenter: "top-4 left-1/2 -translate-x-1/2", ToastBottomCenter: "bottom-4 left-1/2 -translate-x-1/2 flex-col-reverse", } const toastBase = "relative overflow-hidden rounded-default shadow-lg border border-neutral-200 border-l-4 bg-white min-w-72 max-w-md transition-[opacity,transform] duration-150 ease-out" var toastTypeBorder = map[ToastType]string{ ToastSuccess: "border-l-green-700", ToastError: "border-l-red-700", ToastWarning: "border-l-yellow-500", ToastInfo: "border-l-sky-800", ToastGeneric: "border-l-neutral-400", } var toastIconColor = map[ToastType]string{ ToastSuccess: "text-green-600", ToastError: "text-red-600", ToastWarning: "text-yellow-600", ToastInfo: "text-sky-700", ToastGeneric: "", } // ToastItem renders a single toast. onDismiss receives the toast's ID when the // close button is pressed. // // progressRef, when non-nil, is attached to the progress bar so a Toaster can drive // it down to zero over the toast's lifetime (see Toaster.Push). Pass nil for a // static bar. func ToastItem(t Toast, onDismiss func(string), progressRef *vdom.Ref) *vdom.VNode { typ := t.Type if typ == "" { typ = ToastInfo } icon := toastTypeIcons[typ] showProgress := t.ShowProgress && t.Duration > 0 row := []vdom.Mod{vdom.Attr("class", "flex items-start gap-3 p-4")} if icon != "" { row = append(row, Icon(icon, 20, cx("shrink-0 mt-0.5", toastIconColor[typ]))) } row = append(row, vdom.Div(vdom.Attr("class", "flex-1 text-sm text-neutral-800"), vdom.Text(t.Message))) if t.Dismissible { dismiss := []vdom.Mod{ vdom.Attr("class", "shrink-0 cursor-pointer text-neutral-400 hover:text-neutral-600 bg-transparent border-0 p-0 transition-colors"), vdom.Attr("aria-label", "Dismiss"), } if onDismiss != nil { id := t.ID dismiss = append(dismiss, vdom.On(vdom.EVENT_CLICK, func() { onDismiss(id) })) } dismiss = append(dismiss, Icon("xmark", 16, "")) row = append(row, vdom.Button(dismiss...)) } mods := []vdom.Mod{ vdom.Attr("class", cx(toastBase, toastTypeBorder[typ])), vdom.Attr("role", "alert"), vdom.Div(row...), } if showProgress { // The bar is declared at full width; a Toaster transitions it to 0 over the // toast's duration, imperatively (see Toaster.Push). Declaring the same style // string on every render is what stops the reconciler's attribute diff from // resetting it back to 100% mid-countdown. bar := []vdom.Mod{ vdom.Attr("class", "h-full bg-neutral-300"), vdom.Attr("style", "width:100%"), } if progressRef != nil { bar = append(bar, vdom.WithRef(progressRef)) } mods = append(mods, vdom.Div(vdom.Attr("class", "h-1 w-full bg-neutral-100"), vdom.Div(bar...), )) } return vdom.Div(mods...) } // ToastProviderProps configures ToastProvider. Position defaults to // ToastBottomRight; MaxToasts defaults to 5 (only the most recent are kept). type ToastProviderProps struct { Position ToastPosition MaxToasts int Toasts []Toast OnDismiss func(string) } // ToastProvider renders its children followed by the fixed toast container. It // replaces the TSX ToastContext.Provider; the wrapper uses display:contents so // it introduces no layout box of its own. func ToastProvider(p ToastProviderProps, children ...*vdom.VNode) *vdom.VNode { position := p.Position if position == "" { position = ToastBottomRight } max := p.MaxToasts if max <= 0 { max = 5 } toasts := p.Toasts if len(toasts) > max { toasts = toasts[len(toasts)-max:] } container := []vdom.Mod{ vdom.Attr("class", cx(toastContainerBase, toastContainerPositions[position])), vdom.Attr("aria-live", "polite"), vdom.Attr("aria-label", "Notifications"), } for _, t := range toasts { container = append(container, ToastItem(t, p.OnDismiss, nil)) } mods := []vdom.Mod{vdom.Attr("class", "contents")} mods = kids(mods, children) mods = append(mods, vdom.Div(container...)) return vdom.Div(mods...) } // ---- Toaster: the managed version ---- // DefaultToastDuration is how long a toast lives when it does not say. 5s is long // enough to read a sentence and short enough not to nag. const DefaultToastDuration = 5000 // ToastSticky, as a Toast.Duration, means "never auto-dismiss": the user has to // close it. It is negative because Go cannot distinguish an unset 0 from a // deliberate one, and an unset duration must mean the sensible default, not // "forever" — a toast that silently piles up is how a notification area rots. const ToastSticky = -1 // Toaster owns a queue of toasts and DISMISSES THEM ON A TIMER. ToastProvider on // its own does not: it renders whatever list it is handed, so a caller using it // directly has to run the clocks itself (and, in practice, forgets — the toasts // then stack up forever). // // Create it once, alongside your signals, and render it once near the root: // // toaster := webui.NewToaster(webui.ToasterOptions{Position: webui.ToastBottomRight}) // … // toaster.Success("Saved.") // auto-dismisses after 5s // toaster.Push(webui.Toast{Message: "Upload failed", Type: webui.ToastError, // Duration: webui.ToastSticky}) // stays until dismissed // // return func() *vdom.VNode { return Div(page, toaster.Render()) } type Toaster struct { opts ToasterOptions toasts *vdom.Signal[[]Toast] // timers and bars are keyed by toast ID: the pending auto-dismiss, and the // progress bar it is counting down. timers map[string]int bars map[string]*vdom.Ref seq int } // ToasterOptions configures a Toaster. type ToasterOptions struct { Position ToastPosition MaxToasts int // DefaultDuration overrides DefaultToastDuration for toasts that do not set one. DefaultDuration int // NoProgress hides the countdown bar (which is otherwise shown on any toast that // auto-dismisses). NoProgress bool } // NewToaster creates the controller. func NewToaster(o ToasterOptions) *Toaster { if o.DefaultDuration == 0 { o.DefaultDuration = DefaultToastDuration } return &Toaster{ opts: o, toasts: vdom.NewSignal([]Toast{}), timers: map[string]int{}, bars: map[string]*vdom.Ref{}, } } // Push adds a toast and schedules its dismissal. An empty ID gets one. Returns the // ID, so a caller can dismiss it early. // // Duration 0 means the default; ToastSticky means never. The countdown is a real // browser timer, so on the server (where SetTimeout is a no-op) nothing is // scheduled — which is correct: SSR has no one to show a toast to. func (t *Toaster) Push(toast Toast) string { if toast.ID == "" { t.seq++ toast.ID = "toast-" + strconv.Itoa(t.seq) } if toast.Type == "" { toast.Type = ToastInfo } if toast.Duration == 0 { toast.Duration = t.opts.DefaultDuration } toast.Dismissible = true toast.ShowProgress = !t.opts.NoProgress && toast.Duration > 0 t.toasts.Set(append(t.toasts.Get(), toast)) if toast.Duration > 0 { id := toast.ID t.timers[id] = wasmruntime.SetTimeout(toast.Duration, func() { delete(t.timers, id) t.Dismiss(id) }) t.startCountdown(id, toast.Duration) } return toast.ID } // startCountdown drives the progress bar from full to empty over the toast's life. // // It runs imperatively, not through a signal: a signal write per animation frame // would re-render the whole application for a 1px-high bar. The bar is declared at // width:100%, so it paints full first; then a transition takes it to 0. func (t *Toaster) startCountdown(id string, duration int) { if t.opts.NoProgress { return } ref := t.bar(id) wasmruntime.AfterRender(func() { if !ref.Mounted() { return } wasmruntime.SetStyle(ref, "transition", "width "+strconv.Itoa(duration)+"ms linear") // One frame later: the browser has to commit width:100% before it can animate // away from it. Setting both in the same frame just jumps to 0. wasmruntime.RAF(func() { wasmruntime.SetStyle(ref, "width", "0%") }) }) } func (t *Toaster) bar(id string) *vdom.Ref { r, ok := t.bars[id] if !ok { r = vdom.NewRef() t.bars[id] = r } return r } // Success / Error / Warning / Info push a toast of that type with the default // duration — the common case. func (t *Toaster) Success(msg string) string { return t.Push(Toast{Message: msg, Type: ToastSuccess}) } func (t *Toaster) Error(msg string) string { return t.Push(Toast{Message: msg, Type: ToastError}) } func (t *Toaster) Warning(msg string) string { return t.Push(Toast{Message: msg, Type: ToastWarning}) } func (t *Toaster) Info(msg string) string { return t.Push(Toast{Message: msg, Type: ToastInfo}) } // Dismiss removes a toast now, cancelling its pending auto-dismiss. func (t *Toaster) Dismiss(id string) { if timer, ok := t.timers[id]; ok { wasmruntime.ClearTimeout(timer) delete(t.timers, id) } delete(t.bars, id) next := make([]Toast, 0, len(t.toasts.Get())) for _, toast := range t.toasts.Get() { if toast.ID != id { next = append(next, toast) } } t.toasts.Set(next) } // Clear removes every toast and cancels every pending timer. func (t *Toaster) Clear() { for _, timer := range t.timers { wasmruntime.ClearTimeout(timer) } t.timers = map[string]int{} t.bars = map[string]*vdom.Ref{} t.toasts.Set(nil) } // Toasts is the current queue. func (t *Toaster) Toasts() []Toast { return t.toasts.Get() } // Render draws the toast container (and any children, unchanged — so it can wrap a // subtree, as the TSX provider did). func (t *Toaster) Render(children ...*vdom.VNode) *vdom.VNode { position := t.opts.Position if position == "" { position = ToastBottomRight } limit := t.opts.MaxToasts if limit <= 0 { limit = 5 } toasts := t.toasts.Get() if len(toasts) > limit { toasts = toasts[len(toasts)-limit:] } container := []vdom.Mod{ vdom.Attr("class", cx(toastContainerBase, toastContainerPositions[position])), vdom.Attr("aria-live", "polite"), vdom.Attr("aria-label", "Notifications"), } for _, toast := range toasts { container = append(container, ToastItem(toast, t.Dismiss, t.bar(toast.ID))) } mods := []vdom.Mod{vdom.Attr("class", "contents")} mods = kids(mods, children) mods = append(mods, vdom.Div(container...)) return vdom.Div(mods...) }